android/ui: add support for logging in using a custom control server (#266)

fixes ENG-2871

Adds a menu option under the FUS for logging in with your custom control server.  Follows the general pattern used on macOS.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
pull/265/head
Jonathan Nobels 8 months ago committed by GitHub
parent 77f720dba7
commit a321d84dba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -63,6 +63,11 @@ typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit
class Client(private val scope: CoroutineScope) {
private val TAG = Client::class.simpleName
fun start(options: Ipn.Options, responseHandler: (Result<Unit>) -> Unit) {
val body = Json.encodeToString(options).toByteArray()
return post(Endpoint.START, body, responseHandler = responseHandler)
}
fun status(responseHandler: StatusResponseHandler) {
get(Endpoint.STATUS, responseHandler = responseHandler)
}

@ -69,6 +69,7 @@ class Ipn {
@Serializable
data class MaskedPrefs(
var ControlURLSet: Boolean? = null,
var RouteAllSet: Boolean? = null,
var CorpDNSSet: Boolean? = null,
var ExitNodeIDSet: Boolean? = null,
@ -79,6 +80,13 @@ class Ipn {
var ForceDaemonSet: Boolean? = null,
var HostnameSet: Boolean? = null,
) {
var ControlURL: String? = null
set(value) {
field = value
ControlURLSet = true
}
var RouteAll: Boolean? = null
set(value) {
field = value
@ -182,6 +190,12 @@ class Ipn {
}
@Serializable data class FileTarget(var Node: Tailcfg.Node, var PeerAPIURL: String)
@Serializable
data class Options(
var FrontendLogID: String? = null,
var Prefs: Prefs? = null,
)
}
class Persist {

@ -10,7 +10,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.tailscale.ipn.R
enum class ErrorDialogType {
INVALID_CUSTOM_URL,
LOGOUT_FAILED,
SWITCH_USER_FAILED,
ADD_PROFILE_FAILED,
@ -20,6 +22,7 @@ enum class ErrorDialogType {
val message: Int
get() {
return when (this) {
INVALID_CUSTOM_URL -> R.string.invalidCustomUrl
LOGOUT_FAILED -> R.string.logout_failed
SWITCH_USER_FAILED -> R.string.switch_user_failed
ADD_PROFILE_FAILED -> R.string.add_profile_failed
@ -31,6 +34,7 @@ enum class ErrorDialogType {
val title: Int
get() {
return when (this) {
INVALID_CUSTOM_URL -> R.string.invalidCustomURLTitle
LOGOUT_FAILED -> R.string.logout_failed_title
SWITCH_USER_FAILED -> R.string.switch_user_failed_title
ADD_PROFILE_FAILED -> R.string.add_profile_failed_title

@ -5,6 +5,7 @@ package com.tailscale.ipn.ui.view
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
@ -29,15 +30,22 @@ data class BackNavigation(
)
// Header view for all secondary screens
// @see TopAppBar actions for additional actions (usually a row of icons)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Header(@StringRes title: Int = 0, titleText: String? = null, onBack: (() -> Unit)? = null) {
fun Header(
@StringRes title: Int = 0,
titleText: String? = null,
actions: @Composable RowScope.() -> Unit = {},
onBack: (() -> Unit)? = null
) {
TopAppBar(
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
titleContentColor = MaterialTheme.colorScheme.primary,
),
actions = actions,
title = { Text(titleText ?: stringResource(title)) },
navigationIcon = { onBack?.let { BackArrow(action = it) } },
)
@ -68,9 +76,9 @@ fun SimpleActivityIndicator(size: Int = 32) {
@Composable
fun ActivityIndicator(progress: Double, size: Int = 32) {
LinearProgressIndicator(
progress = { progress.toFloat() },
modifier = Modifier.width(size.dp),
color = ts_color_light_blue,
trackColor = MaterialTheme.colorScheme.secondary,
progress = { progress.toFloat() },
modifier = Modifier.width(size.dp),
color = ts_color_light_blue,
trackColor = MaterialTheme.colorScheme.secondary,
)
}

@ -3,18 +3,35 @@
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
@ -33,60 +50,125 @@ fun UserSwitcherView(
val users = viewModel.loginProfiles.collectAsState().value
val currentUser = viewModel.loggedInUser.collectAsState().value
val showHeaderMenu = viewModel.showHeaderMenu.collectAsState().value
Scaffold(topBar = { Header(R.string.accounts, onBack = nav.onBack) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)) {
val showDialog = viewModel.showDialog.collectAsState().value
Scaffold(
topBar = {
Header(
R.string.accounts,
onBack = nav.onBack,
actions = {
Row {
FusMenu(viewModel = viewModel)
IconButton(onClick = { viewModel.showHeaderMenu.set(!showHeaderMenu) }) {
Icon(Icons.Default.MoreVert, "menu")
}
}
})
}) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)) {
val showErrorDialog = viewModel.errorDialog.collectAsState().value
// Show the error overlay if need be
showDialog?.let { ErrorDialog(type = it, action = { viewModel.showDialog.set(null) }) }
// Show the error overlay if need be
showErrorDialog?.let {
ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) })
}
// When switch is invoked, this stores the ID of the user we're trying to switch to
// so we can decorate it with a spinner. The actual logged in user will not change
// until
// we get our first netmap update back with the new userId for SelfNode.
// (jonathan) TODO: This user switch is not immediate. We may need to represent the
// "switching users" state globally (if ipnState is insufficient)
val nextUserId = remember { mutableStateOf<String?>(null) }
// When switch is invoked, this stores the ID of the user we're trying to switch to
// so we can decorate it with a spinner. The actual logged in user will not change
// until
// we get our first netmap update back with the new userId for SelfNode.
// (jonathan) TODO: This user switch is not immediate. We may need to represent the
// "switching users" state globally (if ipnState is insufficient)
val nextUserId = remember { mutableStateOf<String?>(null) }
LazyColumn {
itemsWithDividers(users ?: emptyList()) { user ->
if (user.ID == currentUser?.ID) {
UserView(profile = user, actionState = UserActionState.CURRENT)
} else {
val state =
if (user.ID == nextUserId.value) UserActionState.SWITCHING
else UserActionState.NONE
UserView(
profile = user,
actionState = state,
onClick = {
nextUserId.value = user.ID
viewModel.switchProfile(user) {
if (it.isFailure) {
viewModel.showDialog.set(ErrorDialogType.LOGOUT_FAILED)
nextUserId.value = null
} else {
onNavigateHome()
}
}
})
LazyColumn {
itemsWithDividers(users ?: emptyList()) { user ->
if (user.ID == currentUser?.ID) {
UserView(profile = user, actionState = UserActionState.CURRENT)
} else {
val state =
if (user.ID == nextUserId.value) UserActionState.SWITCHING
else UserActionState.NONE
UserView(
profile = user,
actionState = state,
onClick = {
nextUserId.value = user.ID
viewModel.switchProfile(user) {
if (it.isFailure) {
viewModel.errorDialog.set(ErrorDialogType.LOGOUT_FAILED)
nextUserId.value = null
} else {
onNavigateHome()
}
}
})
}
}
item {
Lists.SectionDivider()
SettingRow(viewModel.addProfileSetting)
Lists.ItemDivider()
SettingRow(viewModel.loginSetting)
if (currentUser != null) {
Lists.ItemDivider()
SettingRow(viewModel.logoutSetting)
}
}
}
}
}
}
@Composable
fun FusMenu(viewModel: UserSwitcherViewModel) {
var url by remember { mutableStateOf("") }
val expanded = viewModel.showHeaderMenu.collectAsState().value
DropdownMenu(
expanded = expanded,
onDismissRequest = {
url = ""
viewModel.showHeaderMenu.set(false)
},
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)) {
DropdownMenuItem(
onClick = {},
text = {
Column {
Text(
stringResource(id = R.string.custom_control_menu),
style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.padding(2.dp))
Text(
stringResource(id = R.string.custom_control_menu_desc),
style = MaterialTheme.typography.bodySmall)
Spacer(modifier = Modifier.padding(8.dp))
OutlinedTextField(
colors =
TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent),
textStyle = MaterialTheme.typography.bodyMedium,
value = url,
onValueChange = { url = it },
placeholder = {
Text(
stringResource(id = R.string.custom_control_placeholder),
style = MaterialTheme.typography.bodySmall)
})
Spacer(modifier = Modifier.padding(8.dp))
item {
Lists.SectionDivider()
SettingRow(viewModel.addProfileSetting)
Lists.ItemDivider()
SettingRow(viewModel.loginSetting)
if (currentUser != null){
Lists.ItemDivider()
SettingRow(viewModel.logoutSetting)
PrimaryActionButton(onClick = { viewModel.setControlURL(url) }) {
Text(stringResource(id = R.string.add_account_short))
}
}
}
}
}
}
})
}
}

@ -3,7 +3,11 @@
package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.view.ErrorDialogType
import kotlinx.coroutines.flow.MutableStateFlow
@ -12,7 +16,10 @@ import kotlinx.coroutines.flow.StateFlow
class UserSwitcherViewModel : IpnViewModel() {
// Set to a non-null value to show the appropriate error dialog
val showDialog: StateFlow<ErrorDialogType?> = MutableStateFlow(null)
val errorDialog: StateFlow<ErrorDialogType?> = MutableStateFlow(null)
// True if we should render the kebab menu
val showHeaderMenu: StateFlow<Boolean> = MutableStateFlow(false)
val loginSetting =
Setting(titleRes = R.string.reauthenticate, type = SettingType.NAV, onClick = { login {} })
@ -25,7 +32,7 @@ class UserSwitcherViewModel : IpnViewModel() {
onClick = {
logout {
if (it.isFailure) {
showDialog.set(ErrorDialogType.LOGOUT_FAILED)
errorDialog.set(ErrorDialogType.LOGOUT_FAILED)
}
}
})
@ -37,8 +44,53 @@ class UserSwitcherViewModel : IpnViewModel() {
onClick = {
addProfile {
if (it.isFailure) {
showDialog.set(ErrorDialogType.ADD_PROFILE_FAILED)
errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED)
}
}
})
// Sets the custom control URL and immediatly invokes the login flow
fun setControlURL(urlStr: String) {
// Some basic checks that the entered URL is "reasonable". The underlying
// localAPIClient will use the default server if we give it a broken URL,
// but we can make sure we can construct a URL from the input string and
// ensure it has an http/https scheme
when (urlStr.startsWith("http") && urlStr.contains("://") && urlStr.length > 7) {
false -> errorDialog.set(ErrorDialogType.INVALID_CUSTOM_URL)
true -> {
showHeaderMenu.set(false)
// We need to have the current prefs to set them back with the new control URL
val prefs = Notifier.prefs.value
if (prefs == null) {
errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED)
return
}
// The basic flow for logging in with a custom control URL is to add a profile,
// call start with prefs that include the control URL pref, then
// start an interactive login.
val fail: (Throwable) -> Unit = { errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) }
val login = {
Client(viewModelScope).startLoginInteractive { startLogin -> startLogin.onFailure(fail) }
}
val start = {
prefs.ControlURL = urlStr
val options = Ipn.Options(Prefs = prefs)
Client(viewModelScope).start(options) { start ->
start.onFailure(fail).onSuccess { login() }
}
}
Client(viewModelScope).addProfile { addProfile ->
addProfile.onFailure(fail).onSuccess { start() }
}
}
}
}
}

@ -84,10 +84,16 @@
<string name="logout_failed">Unable to logout at this time. Please try again.</string>
<string name="error">Error</string>
<string name="accounts">Accounts</string>
<string name="add_account">Add Another Account…</string>
<string name="add_account">Add Another Account</string>
<string name="add_account_short">Add Account</string>
<string name="reauthenticate">Reauthenticate</string>
<string name="switch_user_failed">Unable to switch users. Please try again.</string>
<string name="add_profile_failed">Unable to add a new profile. Please try again.</string>
<string name="invalidCustomUrl">Please enter a valid URL in the form https://server.com</string>
<string name="invalidCustomURLTitle">Invalid URL</string>
<string name="custom_control_menu">Add account using alternate server</string>
<string name="custom_control_menu_desc">Enter the URL of an alternate Tailscale server or your self-hosted Headscale server.</string>
<string name="custom_control_placeholder">https://my.custom.server.com</string>
<!-- Strings for ExitNode picker -->
<string name="choose_exit_node">Choose Exit Node</string>
@ -205,4 +211,5 @@
<string name="getStarted">Get Started</string>
<string name="welcome1">Tailscale is a mesh VPN for securely connecting your devices. All connections are device-to-device, so we never see your data.\n\nWe collect and use your email address and name, as well as your device name, OS version, and IP address in order to help you to connect your devices and manage your settings. We log when you are connected to your network.\n</string>
</resources>

Loading…
Cancel
Save