android: show locked out view

MainViewModel listens for tailnet lock status, and MainView shows "admin signing required" messaging for devices that require signing.
This also refactors ConnectView a bit for readability.

Fixes tailscale/corp#23657

Signed-off-by: kari-ts <kari@tailscale.com>
kari/tkalockedout
kari-ts 1 year ago
parent be89cb10fe
commit d83564f22a

@ -121,6 +121,7 @@ fun MainView(
) { ) {
val currentPingDevice by viewModel.pingViewModel.peer.collectAsState() val currentPingDevice by viewModel.pingViewModel.peer.collectAsState()
val healthIcon by viewModel.healthIcon.collectAsState() val healthIcon by viewModel.healthIcon.collectAsState()
val isLockedOut by viewModel.isLockedOut.collectAsState()
LoadingIndicator.Wrap { LoadingIndicator.Wrap {
Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets -> Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets ->
@ -213,24 +214,38 @@ fun MainView(
when (state) { when (state) {
Ipn.State.Running -> { Ipn.State.Running -> {
if (isLockedOut){
ConnectView(
state,
isPrepared,
isLockedOut,
state != Ipn.State.Stopping,
user,
{ viewModel.toggleVpn() },
{ viewModel.login() },
loginAtUrl,
netmap?.SelfNode,
{ viewModel.showVPNPermissionLauncherIfUnauthorized() })
} else {
PromptPermissionsIfNecessary() PromptPermissionsIfNecessary()
viewModel.showVPNPermissionLauncherIfUnauthorized() viewModel.showVPNPermissionLauncherIfUnauthorized()
if (showKeyExpiry) { if (showKeyExpiry) {
ExpiryNotification(netmap = netmap, action = { viewModel.login() }) ExpiryNotification(netmap = netmap, action = { viewModel.login() })
} }
if (showExitNodePicker.value == ShowHide.Show) { if (showExitNodePicker.value == ShowHide.Show) {
ExitNodeStatus( ExitNodeStatus(
navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
} }
PeerList( PeerList(
viewModel = viewModel, viewModel = viewModel,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails, onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
onSearch = { viewModel.searchPeers(it) }) onSearch = { viewModel.searchPeers(it) })
}
} }
Ipn.State.NoState, Ipn.State.NoState,
Ipn.State.Starting -> StartingView() Ipn.State.Starting -> StartingView()
@ -241,6 +256,7 @@ fun MainView(
// If Tailscale is stopping, don't automatically restart; wait for user to take // If Tailscale is stopping, don't automatically restart; wait for user to take
// action (eg, if the user connected to another VPN). // action (eg, if the user connected to another VPN).
state != Ipn.State.Stopping, state != Ipn.State.Stopping,
isLockedOut,
user, user,
{ viewModel.toggleVpn() }, { viewModel.toggleVpn() },
{ viewModel.login() }, { viewModel.login() },
@ -417,6 +433,7 @@ fun ConnectView(
state: Ipn.State, state: Ipn.State,
isPrepared: Boolean, isPrepared: Boolean,
shouldStartAutomatically: Boolean, shouldStartAutomatically: Boolean,
isLockedOut: Boolean,
user: IpnLocal.LoginProfile?, user: IpnLocal.LoginProfile?,
connectAction: () -> Unit, connectAction: () -> Unit,
loginAction: () -> Unit, loginAction: () -> Unit,
@ -424,108 +441,165 @@ fun ConnectView(
selfNode: Tailcfg.Node?, selfNode: Tailcfg.Node?,
showVPNPermissionLauncherIfUnauthorized: () -> Unit showVPNPermissionLauncherIfUnauthorized: () -> Unit
) { ) {
LaunchedEffect(isPrepared) { // Handle VPN permission automatically
if (!isPrepared && shouldStartAutomatically) { LaunchedEffect(isPrepared) {
showVPNPermissionLauncherIfUnauthorized() if (!isPrepared && shouldStartAutomatically) {
showVPNPermissionLauncherIfUnauthorized()
}
} }
}
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()) {
Column( if (isLockedOut) {
modifier = Modifier.padding(8.dp).fillMaxWidth(0.7f).fillMaxHeight(), LockedOutView()
verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically), } else if (!isPrepared) {
horizontalAlignment = Alignment.CenterHorizontally, NotPreparedView(connectAction)
) { } else if (state == Ipn.State.NeedsMachineAuth) {
if (!isPrepared) { MachineAuthView(selfNode, loginAtUrlAction)
TailscaleLogoView(modifier = Modifier.size(50.dp)) } else if (state != Ipn.State.NeedsLogin && user != null && !user.isEmpty()) {
Spacer(modifier = Modifier.size(1.dp)) NotConnectedView(user, connectAction)
Text( } else {
text = stringResource(id = R.string.welcome_to_tailscale), WelcomeView(loginAction)
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center)
Text(
stringResource(R.string.give_permissions),
style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Center)
Spacer(modifier = Modifier.size(1.dp))
PrimaryActionButton(onClick = connectAction) {
Text(
text = stringResource(id = R.string.connect),
fontSize = MaterialTheme.typography.titleMedium.fontSize)
}
} else 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, @Composable
modifier = Modifier.size(40.dp), fun LockedOutView() {
tint = MaterialTheme.colorScheme.disabled) Column(
Text( modifier = Modifier
text = stringResource(id = R.string.not_connected), .fillMaxHeight()
fontSize = MaterialTheme.typography.titleMedium.fontSize, .fillMaxWidth(),
fontWeight = FontWeight.SemiBold, verticalArrangement = Arrangement.Center,
textAlign = TextAlign.Center, horizontalAlignment = Alignment.CenterHorizontally
fontFamily = MaterialTheme.typography.titleMedium.fontFamily) ) {
val tailnetName = user.NetworkProfile?.DomainName ?: "" Icon(
Text( modifier = Modifier.size(40.dp),
buildAnnotatedString { imageVector = Icons.Outlined.Lock,
append(stringResource(id = R.string.connect_to_tailnet_prefix)) contentDescription = "Device requires signing"
pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) )
append(tailnetName) Text(
pop() text = stringResource(id = R.string.admin_signing_required),
append(stringResource(id = R.string.connect_to_tailnet_suffix)) style = MaterialTheme.typography.titleMedium,
}, textAlign = TextAlign.Center
fontSize = MaterialTheme.typography.titleMedium.fontSize, )
fontWeight = FontWeight.Normal, Text(
textAlign = TextAlign.Center, text = stringResource(id = R.string.admin_signing_explainer),
) style = MaterialTheme.typography.bodyMedium,
Spacer(modifier = Modifier.size(1.dp)) textAlign = TextAlign.Center
PrimaryActionButton(onClick = connectAction) { )
Text( }
text = stringResource(id = R.string.connect), }
fontSize = MaterialTheme.typography.titleMedium.fontSize)
} @Composable
} else { fun NotPreparedView(connectAction: () -> Unit) {
TailscaleLogoView(modifier = Modifier.size(50.dp)) TailscaleLogoView(modifier = Modifier.size(50.dp))
Spacer(modifier = Modifier.size(1.dp)) Spacer(modifier = Modifier.size(1.dp))
Text( Text(
text = stringResource(id = R.string.welcome_to_tailscale), text = stringResource(id = R.string.welcome_to_tailscale),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center) textAlign = TextAlign.Center
Text( )
stringResource(R.string.login_to_join_your_tailnet), Text(
style = MaterialTheme.typography.titleSmall, stringResource(R.string.give_permissions),
textAlign = TextAlign.Center) style = MaterialTheme.typography.titleSmall,
Spacer(modifier = Modifier.size(1.dp)) textAlign = TextAlign.Center
PrimaryActionButton(onClick = loginAction) { )
Spacer(modifier = Modifier.size(1.dp))
PrimaryActionButton(onClick = connectAction) {
Text(
text = stringResource(id = R.string.connect),
fontSize = MaterialTheme.typography.titleMedium.fontSize
)
}
}
@Composable
fun MachineAuthView(selfNode: Tailcfg.Node?, loginAtUrlAction: (String) -> Unit) {
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(
text = stringResource(id = R.string.log_in), text = stringResource(id = R.string.open_admin_console),
fontSize = MaterialTheme.typography.titleMedium.fontSize) fontSize = MaterialTheme.typography.titleMedium.fontSize
} )
} }
}
} }
} }
@Composable
fun NotConnectedView(user: IpnLocal.LoginProfile, connectAction: () -> Unit) {
Icon(
painter = painterResource(id = R.drawable.power),
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.disabled
)
Text(
text = stringResource(id = R.string.not_connected),
fontSize = MaterialTheme.typography.titleMedium.fontSize,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center
)
val tailnetName = user.NetworkProfile?.DomainName ?: ""
Text(
buildAnnotatedString {
append(stringResource(id = R.string.connect_to_tailnet_prefix))
pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
append(tailnetName)
pop()
append(stringResource(id = R.string.connect_to_tailnet_suffix))
},
fontSize = MaterialTheme.typography.titleMedium.fontSize,
fontWeight = FontWeight.Normal,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.size(1.dp))
PrimaryActionButton(onClick = connectAction) {
Text(
text = stringResource(id = R.string.connect),
fontSize = MaterialTheme.typography.titleMedium.fontSize
)
}
}
@Composable
fun WelcomeView(loginAction: () -> Unit) {
TailscaleLogoView(modifier = Modifier.size(50.dp))
Spacer(modifier = Modifier.size(1.dp))
Text(
text = stringResource(id = R.string.welcome_to_tailscale),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center
)
Text(
stringResource(R.string.login_to_join_your_tailnet),
style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.size(1.dp))
PrimaryActionButton(onClick = loginAction) {
Text(
text = stringResource(id = R.string.log_in),
fontSize = MaterialTheme.typography.titleMedium.fontSize
)
}
} }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)

@ -14,6 +14,7 @@ import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App import com.tailscale.ipn.App
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Ipn.State import com.tailscale.ipn.ui.model.Ipn.State
import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.Tailcfg
@ -75,6 +76,9 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
val isVpnActive: StateFlow<Boolean> = vpnViewModel.vpnActive val isVpnActive: StateFlow<Boolean> = vpnViewModel.vpnActive
// If tailnet lock is enabled, whether the node is locked out
val isLockedOut: StateFlow<Boolean> = MutableStateFlow(false)
// Icon displayed in the button to present the health view // Icon displayed in the button to present the health view
val healthIcon: StateFlow<Int?> = MutableStateFlow(null) val healthIcon: StateFlow<Int?> = MutableStateFlow(null)
@ -152,6 +156,16 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
viewModelScope.launch { viewModelScope.launch {
App.get().healthNotifier?.currentIcon?.collect { icon -> healthIcon.set(icon) } App.get().healthNotifier?.currentIcon?.collect { icon -> healthIcon.set(icon) }
} }
Client(viewModelScope).tailnetLockStatus { result ->
result.onSuccess { status ->
if (status.Enabled == true && status.NodeKeySigned == false) {
isLockedOut.set(true)
} else {
isLockedOut.set(false)
}
}
}
} }
fun showVPNPermissionLauncherIfUnauthorized() { fun showVPNPermissionLauncherIfUnauthorized() {

@ -69,6 +69,8 @@
<string name="machine_auth_explainer">This device must be approved by an administrator before it can connect to the tailnet.</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="open_admin_console">Open admin console</string>
<string name="needs_machine_auth">Authorization required</string> <string name="needs_machine_auth">Authorization required</string>
<string name="admin_signing_required">Admin signing required</string>
<string name="admin_signing_explainer">This device must be signed by an administrator in this tailnet. Please contact your network administrator for assistance.</string>
<string name="disable">Disable</string> <string name="disable">Disable</string>
<string name="enable">Enable</string> <string name="enable">Enable</string>
<string name="stop">Stop</string> <string name="stop">Stop</string>

Loading…
Cancel
Save