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,6 +214,19 @@ 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()
@ -232,6 +246,7 @@ fun MainView(
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()
else -> { else -> {
@ -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,68 +441,122 @@ fun ConnectView(
selfNode: Tailcfg.Node?, selfNode: Tailcfg.Node?,
showVPNPermissionLauncherIfUnauthorized: () -> Unit showVPNPermissionLauncherIfUnauthorized: () -> Unit
) { ) {
// Handle VPN permission automatically
LaunchedEffect(isPrepared) { LaunchedEffect(isPrepared) {
if (!isPrepared && shouldStartAutomatically) { if (!isPrepared && shouldStartAutomatically) {
showVPNPermissionLauncherIfUnauthorized() 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()) {
if (isLockedOut) {
LockedOutView()
} else if (!isPrepared) {
NotPreparedView(connectAction)
} else if (state == Ipn.State.NeedsMachineAuth) {
MachineAuthView(selfNode, loginAtUrlAction)
} else if (state != Ipn.State.NeedsLogin && user != null && !user.isEmpty()) {
NotConnectedView(user, connectAction)
} else {
WelcomeView(loginAction)
}
}
}
}
@Composable
fun LockedOutView() {
Column( Column(
modifier = Modifier.padding(8.dp).fillMaxWidth(0.7f).fillMaxHeight(), modifier = Modifier
verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically), .fillMaxHeight()
horizontalAlignment = Alignment.CenterHorizontally, .fillMaxWidth(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
if (!isPrepared) { Icon(
modifier = Modifier.size(40.dp),
imageVector = Icons.Outlined.Lock,
contentDescription = "Device requires signing"
)
Text(
text = stringResource(id = R.string.admin_signing_required),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center
)
Text(
text = stringResource(id = R.string.admin_signing_explainer),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
}
}
@Composable
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( Text(
stringResource(R.string.give_permissions), stringResource(R.string.give_permissions),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Center) textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.size(1.dp)) Spacer(modifier = Modifier.size(1.dp))
PrimaryActionButton(onClick = connectAction) { PrimaryActionButton(onClick = connectAction) {
Text( Text(
text = stringResource(id = R.string.connect), text = stringResource(id = R.string.connect),
fontSize = MaterialTheme.typography.titleMedium.fontSize) fontSize = MaterialTheme.typography.titleMedium.fontSize
)
} }
} else if (state == Ipn.State.NeedsMachineAuth) { }
@Composable
fun MachineAuthView(selfNode: Tailcfg.Node?, loginAtUrlAction: (String) -> Unit) {
Icon( Icon(
modifier = Modifier.size(40.dp), modifier = Modifier.size(40.dp),
imageVector = Icons.Outlined.Lock, imageVector = Icons.Outlined.Lock,
contentDescription = "Device requires authentication") contentDescription = "Device requires authentication"
)
Text( Text(
text = stringResource(id = R.string.machine_auth_required), text = stringResource(id = R.string.machine_auth_required),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center) textAlign = TextAlign.Center
)
Text( Text(
text = stringResource(id = R.string.machine_auth_explainer), text = stringResource(id = R.string.machine_auth_explainer),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center) textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.size(1.dp)) Spacer(modifier = Modifier.size(1.dp))
selfNode?.let { selfNode?.let {
PrimaryActionButton(onClick = { loginAtUrlAction(it.nodeAdminUrl) }) { PrimaryActionButton(onClick = { loginAtUrlAction(it.nodeAdminUrl) }) {
Text( Text(
text = stringResource(id = R.string.open_admin_console), text = stringResource(id = R.string.open_admin_console),
fontSize = MaterialTheme.typography.titleMedium.fontSize) fontSize = MaterialTheme.typography.titleMedium.fontSize
)
} }
} }
} else if (state != Ipn.State.NeedsLogin && user != null && !user.isEmpty()) { }
@Composable
fun NotConnectedView(user: IpnLocal.LoginProfile, connectAction: () -> Unit) {
Icon( Icon(
painter = painterResource(id = R.drawable.power), painter = painterResource(id = R.drawable.power),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(40.dp), modifier = Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.disabled) tint = MaterialTheme.colorScheme.disabled
)
Text( Text(
text = stringResource(id = R.string.not_connected), text = stringResource(id = R.string.not_connected),
fontSize = MaterialTheme.typography.titleMedium.fontSize, fontSize = MaterialTheme.typography.titleMedium.fontSize,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center, textAlign = TextAlign.Center
fontFamily = MaterialTheme.typography.titleMedium.fontFamily) )
val tailnetName = user.NetworkProfile?.DomainName ?: "" val tailnetName = user.NetworkProfile?.DomainName ?: ""
Text( Text(
buildAnnotatedString { buildAnnotatedString {
@ -497,34 +568,37 @@ fun ConnectView(
}, },
fontSize = MaterialTheme.typography.titleMedium.fontSize, fontSize = MaterialTheme.typography.titleMedium.fontSize,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
textAlign = TextAlign.Center, textAlign = TextAlign.Center
) )
Spacer(modifier = Modifier.size(1.dp)) Spacer(modifier = Modifier.size(1.dp))
PrimaryActionButton(onClick = connectAction) { PrimaryActionButton(onClick = connectAction) {
Text( Text(
text = stringResource(id = R.string.connect), text = stringResource(id = R.string.connect),
fontSize = MaterialTheme.typography.titleMedium.fontSize) fontSize = MaterialTheme.typography.titleMedium.fontSize
)
} }
} else { }
@Composable
fun WelcomeView(loginAction: () -> 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( Text(
stringResource(R.string.login_to_join_your_tailnet), stringResource(R.string.login_to_join_your_tailnet),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Center) textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.size(1.dp)) Spacer(modifier = Modifier.size(1.dp))
PrimaryActionButton(onClick = loginAction) { PrimaryActionButton(onClick = loginAction) {
Text( Text(
text = stringResource(id = R.string.log_in), text = stringResource(id = R.string.log_in),
fontSize = MaterialTheme.typography.titleMedium.fontSize) fontSize = MaterialTheme.typography.titleMedium.fontSize
} )
}
}
}
} }
} }

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