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") })
composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) {
MainView(navigation = mainViewNav)
MainView(loginAtUrl = ::login, navigation = mainViewNav)
}
composable("settings") { SettingsView(settingsNav) }
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }

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

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

@ -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<PeerSet>())
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

@ -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 = {}
) {

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

@ -60,7 +60,10 @@
<string name="deviceKeyNeverExpires">Device key does not expire</string>
<string name="deviceKeyExpires">Device key expires %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 -->
<string name="os">OS</string>

Loading…
Cancel
Save