From 1a822a27bd85e3a8415dbc07ed2b48e75af82e8f Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Fri, 12 Apr 2024 10:06:23 -0400 Subject: [PATCH] android/ui: handle NeedsMachineAuth state Fixes tailscale/corp#19119 Adds a variation on the ConnectView to render a header and explainer text for the NeedsMachineAuth state. A button to take you directly to the admin page is presented if you are an admin. Signed-off-by: Jonathan Nobels --- .../java/com/tailscale/ipn/MainActivity.kt | 2 +- .../com/tailscale/ipn/ui/model/TailCfg.kt | 11 ++++ .../tailscale/ipn/ui/util/DisplayAddress.kt | 2 +- .../com/tailscale/ipn/ui/view/MainView.kt | 55 +++++++++++++++---- .../com/tailscale/ipn/ui/view/PeerView.kt | 2 +- .../ipn/ui/viewModel/MainViewModel.kt | 2 +- android/src/main/res/values/strings.xml | 7 ++- 7 files changed, 63 insertions(+), 18 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index e45d23f..2924c3a 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -162,7 +162,7 @@ class MainActivity : ComponentActivity() { onNavigateToRunAsExitNode = { navController.navigate("runExitNode") }) composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) { - MainView(navigation = mainViewNav) + MainView(loginAtUrl = ::login, navigation = mainViewNav) } composable("settings") { SettingsView(settingsNav) } composable("exitNodes") { ExitNodePicker(exitNodePickerNav) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt index 6f6cc81..8dbc18a 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt @@ -7,6 +7,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.tailscale.ipn.R +import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.theme.off import com.tailscale.ipn.ui.theme.on import com.tailscale.ipn.ui.util.ComposableStringFormatter @@ -91,6 +92,16 @@ class Tailcfg { Capabilities?.contains("https://tailscale.com/cap/is-admin") == true || CapMap?.contains("https://tailscale.com/cap/is-admin") == true + // Derives the url to directly administer a node + val nodeAdminUrl: String + get() = primaryIPv4Address?.let { "${Links.ADMIN_URL}/machines/${it}" } ?: Links.ADMIN_URL + + val primaryIPv4Address: String? + get() = displayAddresses.firstOrNull { it.type == DisplayAddress.addrType.V4 }?.address + + val primaryIPv6Address: String? + get() = displayAddresses.firstOrNull { it.type == DisplayAddress.addrType.V6 }?.address + // isExitNode reproduces the Go logic in local.go peerStatusFromNode val isExitNode: Boolean = AllowedIPs?.contains("0.0.0.0/0") ?: false && AllowedIPs?.contains("::/0") ?: false diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/DisplayAddress.kt b/android/src/main/java/com/tailscale/ipn/ui/util/DisplayAddress.kt index 87bb03b..ca7849f 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/DisplayAddress.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/DisplayAddress.kt @@ -3,7 +3,7 @@ package com.tailscale.ipn.ui.util -class DisplayAddress(val ip: String) { +class DisplayAddress(ip: String) { enum class addrType { V4, V6, diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index e54b531..a7b549d 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -26,6 +26,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ArrowDropDown import androidx.compose.material.icons.outlined.Clear import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.Button @@ -93,7 +94,7 @@ data class MainViewNavigation( @OptIn(ExperimentalPermissionsApi::class) @Composable -fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewModel()) { +fun MainView(loginAtUrl: (String) -> Unit, navigation: MainViewNavigation, viewModel: MainViewModel = viewModel()) { LoadingIndicator.Wrap { Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets -> Column( @@ -103,7 +104,7 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode val user = viewModel.loggedInUser.collectAsState(initial = null).value val stateVal = viewModel.stateRes.collectAsState(initial = R.string.placeholder).value val stateStr = stringResource(id = stateVal) - val netmap = viewModel.netmap.collectAsState(initial = null) + val netmap = viewModel.netmap.collectAsState(initial = null).value ListItem( colors = MaterialTheme.colorScheme.surfaceContainerListItem, @@ -147,8 +148,7 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode PromptPermissionsIfNecessary() - ExpiryNotificationIfNecessary( - netmap = netmap.value, action = { viewModel.login {} }) + ExpiryNotificationIfNecessary(netmap = netmap, action = { viewModel.login {} }) ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) @@ -159,7 +159,15 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode } Ipn.State.NoState, Ipn.State.Starting -> StartingView() - else -> ConnectView(state, user, { viewModel.toggleVpn() }, { viewModel.login {} }) + else -> { + ConnectView( + state, + user, + { viewModel.toggleVpn() }, + { viewModel.login {} }, + loginAtUrl, + netmap?.SelfNode) + } } } } @@ -186,9 +194,9 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { modifier = Modifier.clickable { navAction() }, colors = if (active) MaterialTheme.colorScheme.primaryListItem - else ListItemDefaults.colors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLowest - ), + else + ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLowest), overlineContent = { Text( stringResource(R.string.exit_node), @@ -252,7 +260,9 @@ fun ConnectView( state: Ipn.State, user: IpnLocal.LoginProfile?, connectAction: () -> Unit, - loginAction: () -> Unit + loginAction: () -> Unit, + loginAtUrlAction: (String) -> Unit, + selfNode: Tailcfg.Node? ) { Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) { @@ -261,7 +271,29 @@ fun ConnectView( verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally, ) { - if (state != Ipn.State.NeedsLogin && user != null && !user.isEmpty()) { + if (state == Ipn.State.NeedsMachineAuth) { + Icon( + modifier = Modifier.size(40.dp), + imageVector = Icons.Outlined.Lock, + contentDescription = "Device requires authentication") + Text( + text = stringResource(id = R.string.machine_auth_required), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center) + Text( + text = stringResource(id = R.string.machine_auth_explainer), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center) + Spacer(modifier = Modifier.size(1.dp)) + selfNode?.let { + PrimaryActionButton( + onClick = { loginAtUrlAction(it.nodeAdminUrl) }) { + Text( + text = stringResource(id = R.string.open_admin_console), + fontSize = MaterialTheme.typography.titleMedium.fontSize) + } + } + } else if (state != Ipn.State.NeedsLogin && user != null && !user.isEmpty()) { Icon( painter = painterResource(id = R.drawable.power), contentDescription = null, @@ -324,8 +356,7 @@ fun PeerList( ) { val peerList = viewModel.peers.collectAsState(initial = emptyList()) val searchTermStr by viewModel.searchTerm.collectAsState(initial = "") - val showNoResults = - derivedStateOf { searchTermStr.isNotEmpty() && peerList.value.isEmpty() }.value + val showNoResults = remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.value.isEmpty() } }.value val netmap = viewModel.netmap.collectAsState() val focusManager = LocalFocusManager.current diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/PeerView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/PeerView.kt index 54587a1..2b1b70f 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/PeerView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/PeerView.kt @@ -28,7 +28,7 @@ fun PeerView( peer: Tailcfg.Node, selfPeer: String? = null, stateVal: Ipn.State? = null, - subtitle: () -> String = { peer.Addresses?.first()?.split("/")?.first() ?: "" }, + subtitle: () -> String = { peer.primaryIPv4Address ?: peer.primaryIPv6Address ?: "" }, onClick: (Tailcfg.Node) -> Unit = {}, trailingContent: @Composable () -> Unit = {} ) { diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index 4598774..692038c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -79,7 +79,7 @@ private fun State?.userStringRes(): Int { State.NoState -> R.string.waiting State.InUseOtherUser -> R.string.placeholder State.NeedsLogin -> R.string.please_login - State.NeedsMachineAuth -> R.string.placeholder + State.NeedsMachineAuth -> R.string.needs_machine_auth State.Stopped -> R.string.stopped State.Starting -> R.string.starting State.Running -> R.string.connected diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 7816f48..ebc2c39 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -60,7 +60,10 @@ Device key does not expire Device key expires %s Device key expired %s - + Admin approval required + This device must be approved by an administrator before it can connect to the tailnet. + Open admin console + Authorization required OS @@ -226,6 +229,6 @@ Get Started Tailscale is a mesh VPN for securely connecting your devices. All connections are device-to-device, so we never see your data. We 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. - Scan this QR code to log in to your tailnet + Scan this QR code to log in to your tailnet