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 <jonathan@tailscale.com>
jonathan/machine_auth
Jonathan Nobels 1 month ago
parent 164a243b77
commit 1a822a27bd

@ -162,7 +162,7 @@ class MainActivity : ComponentActivity() {
onNavigateToRunAsExitNode = { navController.navigate("runExitNode") }) onNavigateToRunAsExitNode = { navController.navigate("runExitNode") })
composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) { composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) {
MainView(navigation = mainViewNav) MainView(loginAtUrl = ::login, navigation = mainViewNav)
} }
composable("settings") { SettingsView(settingsNav) } composable("settings") { SettingsView(settingsNav) }
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) } composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }

@ -7,6 +7,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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
import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.off import com.tailscale.ipn.ui.theme.off
import com.tailscale.ipn.ui.theme.on import com.tailscale.ipn.ui.theme.on
import com.tailscale.ipn.ui.util.ComposableStringFormatter import com.tailscale.ipn.ui.util.ComposableStringFormatter
@ -91,6 +92,16 @@ class Tailcfg {
Capabilities?.contains("https://tailscale.com/cap/is-admin") == true || Capabilities?.contains("https://tailscale.com/cap/is-admin") == true ||
CapMap?.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 // isExitNode reproduces the Go logic in local.go peerStatusFromNode
val isExitNode: Boolean = val isExitNode: Boolean =
AllowedIPs?.contains("0.0.0.0/0") ?: false && AllowedIPs?.contains("::/0") ?: false AllowedIPs?.contains("0.0.0.0/0") ?: false && AllowedIPs?.contains("::/0") ?: false

@ -3,7 +3,7 @@
package com.tailscale.ipn.ui.util package com.tailscale.ipn.ui.util
class DisplayAddress(val ip: String) { class DisplayAddress(ip: String) {
enum class addrType { enum class addrType {
V4, V4,
V6, V6,

@ -26,6 +26,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowDropDown import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.Clear import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.Close 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.Search
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Button import androidx.compose.material3.Button
@ -93,7 +94,7 @@ data class MainViewNavigation(
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewModel()) { fun MainView(loginAtUrl: (String) -> Unit, navigation: MainViewNavigation, viewModel: MainViewModel = viewModel()) {
LoadingIndicator.Wrap { LoadingIndicator.Wrap {
Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets -> Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets ->
Column( Column(
@ -103,7 +104,7 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
val user = viewModel.loggedInUser.collectAsState(initial = null).value val user = viewModel.loggedInUser.collectAsState(initial = null).value
val stateVal = viewModel.stateRes.collectAsState(initial = R.string.placeholder).value val stateVal = viewModel.stateRes.collectAsState(initial = R.string.placeholder).value
val stateStr = stringResource(id = stateVal) val stateStr = stringResource(id = stateVal)
val netmap = viewModel.netmap.collectAsState(initial = null) val netmap = viewModel.netmap.collectAsState(initial = null).value
ListItem( ListItem(
colors = MaterialTheme.colorScheme.surfaceContainerListItem, colors = MaterialTheme.colorScheme.surfaceContainerListItem,
@ -147,8 +148,7 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
PromptPermissionsIfNecessary() PromptPermissionsIfNecessary()
ExpiryNotificationIfNecessary( ExpiryNotificationIfNecessary(netmap = netmap, action = { viewModel.login {} })
netmap = netmap.value, action = { viewModel.login {} })
ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
@ -159,7 +159,15 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
} }
Ipn.State.NoState, Ipn.State.NoState,
Ipn.State.Starting -> StartingView() 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() }, modifier = Modifier.clickable { navAction() },
colors = colors =
if (active) MaterialTheme.colorScheme.primaryListItem if (active) MaterialTheme.colorScheme.primaryListItem
else ListItemDefaults.colors( else
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest ListItemDefaults.colors(
), containerColor = MaterialTheme.colorScheme.surfaceContainerLowest),
overlineContent = { overlineContent = {
Text( Text(
stringResource(R.string.exit_node), stringResource(R.string.exit_node),
@ -252,7 +260,9 @@ fun ConnectView(
state: Ipn.State, state: Ipn.State,
user: IpnLocal.LoginProfile?, user: IpnLocal.LoginProfile?,
connectAction: () -> Unit, connectAction: () -> Unit,
loginAction: () -> Unit loginAction: () -> Unit,
loginAtUrlAction: (String) -> Unit,
selfNode: Tailcfg.Node?
) { ) {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) { Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
@ -261,7 +271,29 @@ fun ConnectView(
verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically), verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally, 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( Icon(
painter = painterResource(id = R.drawable.power), painter = painterResource(id = R.drawable.power),
contentDescription = null, contentDescription = null,
@ -324,8 +356,7 @@ fun PeerList(
) { ) {
val peerList = viewModel.peers.collectAsState(initial = emptyList<PeerSet>()) val peerList = viewModel.peers.collectAsState(initial = emptyList<PeerSet>())
val searchTermStr by viewModel.searchTerm.collectAsState(initial = "") val searchTermStr by viewModel.searchTerm.collectAsState(initial = "")
val showNoResults = val showNoResults = remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.value.isEmpty() } }.value
derivedStateOf { searchTermStr.isNotEmpty() && peerList.value.isEmpty() }.value
val netmap = viewModel.netmap.collectAsState() val netmap = viewModel.netmap.collectAsState()
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current

@ -28,7 +28,7 @@ fun PeerView(
peer: Tailcfg.Node, peer: Tailcfg.Node,
selfPeer: String? = null, selfPeer: String? = null,
stateVal: Ipn.State? = null, stateVal: Ipn.State? = null,
subtitle: () -> String = { peer.Addresses?.first()?.split("/")?.first() ?: "" }, subtitle: () -> String = { peer.primaryIPv4Address ?: peer.primaryIPv6Address ?: "" },
onClick: (Tailcfg.Node) -> Unit = {}, onClick: (Tailcfg.Node) -> Unit = {},
trailingContent: @Composable () -> Unit = {} trailingContent: @Composable () -> Unit = {}
) { ) {

@ -79,7 +79,7 @@ private fun State?.userStringRes(): Int {
State.NoState -> R.string.waiting State.NoState -> R.string.waiting
State.InUseOtherUser -> R.string.placeholder State.InUseOtherUser -> R.string.placeholder
State.NeedsLogin -> R.string.please_login State.NeedsLogin -> R.string.please_login
State.NeedsMachineAuth -> R.string.placeholder State.NeedsMachineAuth -> R.string.needs_machine_auth
State.Stopped -> R.string.stopped State.Stopped -> R.string.stopped
State.Starting -> R.string.starting State.Starting -> R.string.starting
State.Running -> R.string.connected State.Running -> R.string.connected

@ -60,7 +60,10 @@
<string name="deviceKeyNeverExpires">Device key does not expire</string> <string name="deviceKeyNeverExpires">Device key does not expire</string>
<string name="deviceKeyExpires">Device key expires %s</string> <string name="deviceKeyExpires">Device key expires %s</string>
<string name="deviceKeyExpired">Device key expired %s</string> <string name="deviceKeyExpired">Device key expired %s</string>
<string name="machine_auth_required">Admin approval required</string>
<string name="machine_auth_explainer">This device must be approved by an administrator before it can connect to the tailnet.</string>
<string name="open_admin_console">Open admin console</string>
<string name="needs_machine_auth">Authorization required</string>
<!-- Strings for peer details --> <!-- Strings for peer details -->
<string name="os">OS</string> <string name="os">OS</string>
@ -226,6 +229,6 @@
<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.</string> <string name="welcome1">Tailscale is a mesh VPN for securely connecting your devices.</string>
<string name="welcome2">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.</string> <string name="welcome2">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.</string>
<string name="scan_to_connect_to_your_tailnet">Scan this QR code to log in to your tailnet</string> <string name="scan_to_connect_to_your_tailnet">Scan this QR code to log in to your tailnet</string>
</resources> </resources>

Loading…
Cancel
Save