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 7 months 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>

Loading…
Cancel
Save