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) { class Client(private val scope: CoroutineScope) {
private val TAG = Client::class.simpleName 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) { fun status(responseHandler: StatusResponseHandler) {
get(Endpoint.STATUS, responseHandler = responseHandler) get(Endpoint.STATUS, responseHandler = responseHandler)
} }

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

@ -10,7 +10,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import com.tailscale.ipn.R import com.tailscale.ipn.R
enum class ErrorDialogType { enum class ErrorDialogType {
INVALID_CUSTOM_URL,
LOGOUT_FAILED, LOGOUT_FAILED,
SWITCH_USER_FAILED, SWITCH_USER_FAILED,
ADD_PROFILE_FAILED, ADD_PROFILE_FAILED,
@ -20,6 +22,7 @@ enum class ErrorDialogType {
val message: Int val message: Int
get() { get() {
return when (this) { return when (this) {
INVALID_CUSTOM_URL -> R.string.invalidCustomUrl
LOGOUT_FAILED -> R.string.logout_failed LOGOUT_FAILED -> R.string.logout_failed
SWITCH_USER_FAILED -> R.string.switch_user_failed SWITCH_USER_FAILED -> R.string.switch_user_failed
ADD_PROFILE_FAILED -> R.string.add_profile_failed ADD_PROFILE_FAILED -> R.string.add_profile_failed
@ -31,6 +34,7 @@ enum class ErrorDialogType {
val title: Int val title: Int
get() { get() {
return when (this) { return when (this) {
INVALID_CUSTOM_URL -> R.string.invalidCustomURLTitle
LOGOUT_FAILED -> R.string.logout_failed_title LOGOUT_FAILED -> R.string.logout_failed_title
SWITCH_USER_FAILED -> R.string.switch_user_failed_title SWITCH_USER_FAILED -> R.string.switch_user_failed_title
ADD_PROFILE_FAILED -> R.string.add_profile_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.annotation.StringRes
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -29,15 +30,22 @@ data class BackNavigation(
) )
// Header view for all secondary screens // Header view for all secondary screens
// @see TopAppBar actions for additional actions (usually a row of icons)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @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( TopAppBar(
colors = colors =
TopAppBarDefaults.topAppBarColors( TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
titleContentColor = MaterialTheme.colorScheme.primary, titleContentColor = MaterialTheme.colorScheme.primary,
), ),
actions = actions,
title = { Text(titleText ?: stringResource(title)) }, title = { Text(titleText ?: stringResource(title)) },
navigationIcon = { onBack?.let { BackArrow(action = it) } }, navigationIcon = { onBack?.let { BackArrow(action = it) } },
) )
@ -68,9 +76,9 @@ fun SimpleActivityIndicator(size: Int = 32) {
@Composable @Composable
fun ActivityIndicator(progress: Double, size: Int = 32) { fun ActivityIndicator(progress: Double, size: Int = 32) {
LinearProgressIndicator( LinearProgressIndicator(
progress = { progress.toFloat() }, progress = { progress.toFloat() },
modifier = Modifier.width(size.dp), modifier = Modifier.width(size.dp),
color = ts_color_light_blue, color = ts_color_light_blue,
trackColor = MaterialTheme.colorScheme.secondary, trackColor = MaterialTheme.colorScheme.secondary,
) )
} }

@ -3,18 +3,35 @@
package com.tailscale.ipn.ui.view package com.tailscale.ipn.ui.view
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn 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.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.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier 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.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
@ -25,7 +42,7 @@ import com.tailscale.ipn.ui.viewModel.UserSwitcherViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun UserSwitcherView( fun UserSwitcherView(
nav: BackNavigation, nav: BackNavigation,
onNavigateHome: () -> Unit, onNavigateHome: () -> Unit,
viewModel: UserSwitcherViewModel = viewModel() viewModel: UserSwitcherViewModel = viewModel()
@ -33,60 +50,125 @@ fun UserSwitcherView(
val users = viewModel.loginProfiles.collectAsState().value val users = viewModel.loginProfiles.collectAsState().value
val currentUser = viewModel.loggedInUser.collectAsState().value val currentUser = viewModel.loggedInUser.collectAsState().value
val showHeaderMenu = viewModel.showHeaderMenu.collectAsState().value
Scaffold(topBar = { Header(R.string.accounts, onBack = nav.onBack) }) { innerPadding -> Scaffold(
Column( topBar = {
modifier = Modifier.padding(innerPadding).fillMaxWidth(), Header(
verticalArrangement = Arrangement.spacedBy(8.dp)) { R.string.accounts,
val showDialog = viewModel.showDialog.collectAsState().value 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 // Show the error overlay if need be
showDialog?.let { ErrorDialog(type = it, action = { viewModel.showDialog.set(null) }) } 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 // 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 // so we can decorate it with a spinner. The actual logged in user will not change
// until // until
// we get our first netmap update back with the new userId for SelfNode. // 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 // (jonathan) TODO: This user switch is not immediate. We may need to represent the
// "switching users" state globally (if ipnState is insufficient) // "switching users" state globally (if ipnState is insufficient)
val nextUserId = remember { mutableStateOf<String?>(null) } val nextUserId = remember { mutableStateOf<String?>(null) }
LazyColumn { LazyColumn {
itemsWithDividers(users ?: emptyList()) { user -> itemsWithDividers(users ?: emptyList()) { user ->
if (user.ID == currentUser?.ID) { if (user.ID == currentUser?.ID) {
UserView(profile = user, actionState = UserActionState.CURRENT) UserView(profile = user, actionState = UserActionState.CURRENT)
} else { } else {
val state = val state =
if (user.ID == nextUserId.value) UserActionState.SWITCHING if (user.ID == nextUserId.value) UserActionState.SWITCHING
else UserActionState.NONE else UserActionState.NONE
UserView( UserView(
profile = user, profile = user,
actionState = state, actionState = state,
onClick = { onClick = {
nextUserId.value = user.ID nextUserId.value = user.ID
viewModel.switchProfile(user) { viewModel.switchProfile(user) {
if (it.isFailure) { if (it.isFailure) {
viewModel.showDialog.set(ErrorDialogType.LOGOUT_FAILED) viewModel.errorDialog.set(ErrorDialogType.LOGOUT_FAILED)
nextUserId.value = null nextUserId.value = null
} else { } else {
onNavigateHome() 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 { PrimaryActionButton(onClick = { viewModel.setControlURL(url) }) {
Lists.SectionDivider() Text(stringResource(id = R.string.add_account_short))
SettingRow(viewModel.addProfileSetting) }
Lists.ItemDivider()
SettingRow(viewModel.loginSetting)
if (currentUser != null){
Lists.ItemDivider()
SettingRow(viewModel.logoutSetting)
} }
} })
} }
}
}
} }

@ -3,7 +3,11 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.R 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.util.set
import com.tailscale.ipn.ui.view.ErrorDialogType import com.tailscale.ipn.ui.view.ErrorDialogType
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -12,7 +16,10 @@ import kotlinx.coroutines.flow.StateFlow
class UserSwitcherViewModel : IpnViewModel() { class UserSwitcherViewModel : IpnViewModel() {
// Set to a non-null value to show the appropriate error dialog // 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 = val loginSetting =
Setting(titleRes = R.string.reauthenticate, type = SettingType.NAV, onClick = { login {} }) Setting(titleRes = R.string.reauthenticate, type = SettingType.NAV, onClick = { login {} })
@ -25,7 +32,7 @@ class UserSwitcherViewModel : IpnViewModel() {
onClick = { onClick = {
logout { logout {
if (it.isFailure) { if (it.isFailure) {
showDialog.set(ErrorDialogType.LOGOUT_FAILED) errorDialog.set(ErrorDialogType.LOGOUT_FAILED)
} }
} }
}) })
@ -37,8 +44,53 @@ class UserSwitcherViewModel : IpnViewModel() {
onClick = { onClick = {
addProfile { addProfile {
if (it.isFailure) { 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="logout_failed">Unable to logout at this time. Please try again.</string>
<string name="error">Error</string> <string name="error">Error</string>
<string name="accounts">Accounts</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="reauthenticate">Reauthenticate</string>
<string name="switch_user_failed">Unable to switch users. Please try again.</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="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 --> <!-- Strings for ExitNode picker -->
<string name="choose_exit_node">Choose Exit Node</string> <string name="choose_exit_node">Choose Exit Node</string>
@ -205,4 +211,5 @@
<string name="getStarted">Get Started</string> <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> <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> </resources>

Loading…
Cancel
Save