ui: add ManagedByView, hide MDMSettingsView on non-debug builds (#201)

Updates tailscale/corp#18202

- Adds the "Managed by OrganizationName" view we currently offer on iOS.
- Hides the MDM settings debug pane on non-debug builds.
- Refactored SettingsViewModel to take an `IpnManager` instead of an `IpnModel` (@barnstar, let me know whether this makes sense given your future plans)

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
pull/207/head
Andrea Gottardo 8 months ago committed by GitHub
parent bf7bf94b52
commit 7c64091aab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -25,6 +25,7 @@ import com.tailscale.ipn.ui.view.ExitNodePicker
import com.tailscale.ipn.ui.view.MDMSettingsDebugView import com.tailscale.ipn.ui.view.MDMSettingsDebugView
import com.tailscale.ipn.ui.view.MainView import com.tailscale.ipn.ui.view.MainView
import com.tailscale.ipn.ui.view.MainViewNavigation import com.tailscale.ipn.ui.view.MainViewNavigation
import com.tailscale.ipn.ui.view.ManagedByView
import com.tailscale.ipn.ui.view.PeerDetails import com.tailscale.ipn.ui.view.PeerDetails
import com.tailscale.ipn.ui.view.Settings import com.tailscale.ipn.ui.view.Settings
import com.tailscale.ipn.ui.view.SettingsNav import com.tailscale.ipn.ui.view.SettingsNav
@ -58,7 +59,8 @@ class MainActivity : ComponentActivity() {
val settingsNav = SettingsNav( val settingsNav = SettingsNav(
onNavigateToBugReport = { navController.navigate("bugReport") }, onNavigateToBugReport = { navController.navigate("bugReport") },
onNavigateToAbout = { navController.navigate("about") }, onNavigateToAbout = { navController.navigate("about") },
onNavigateToMDMSettings = { navController.navigate("mdmSettings") } onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
onNavigateToManagedBy = { navController.navigate("managedBy") }
) )
composable("main") { composable("main") {
@ -68,7 +70,7 @@ class MainActivity : ComponentActivity() {
) )
} }
composable("settings") { composable("settings") {
Settings(SettingsViewModel(manager.model, manager, settingsNav)) Settings(SettingsViewModel(manager, settingsNav))
} }
composable("exitNodes") { composable("exitNodes") {
ExitNodePicker(ExitNodePickerViewModel(manager.model)) ExitNodePicker(ExitNodePickerViewModel(manager.model))
@ -94,6 +96,9 @@ class MainActivity : ComponentActivity() {
composable("mdmSettings") { composable("mdmSettings") {
MDMSettingsDebugView(manager.mdmSettings) MDMSettingsDebugView(manager.mdmSettings)
} }
composable("managedBy") {
ManagedByView(manager.mdmSettings)
}
} }
} }
} }

@ -0,0 +1,49 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.StringSetting
@Composable
fun ManagedByView(mdmSettings: MDMSettings) {
Surface(color = MaterialTheme.colorScheme.surface) {
Column(
verticalArrangement = Arrangement.spacedBy(
space = 20.dp, alignment = Alignment.CenterVertically
),
horizontalAlignment = Alignment.Start,
modifier = Modifier
.fillMaxWidth()
.safeContentPadding()
) {
mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let {
Text(stringResource(R.string.managed_by_explainer_orgName, it))
} ?: run {
Text(stringResource(R.string.managed_by_explainer))
}
mdmSettings.get(StringSetting.ManagedByCaption)?.let {
if (it.isNotEmpty()) {
Text(it)
}
}
mdmSettings.get(StringSetting.ManagedByURL)?.let {
OpenURLButton(stringResource(R.string.open_support), it)
}
}
}
}

@ -49,7 +49,8 @@ import com.tailscale.ipn.ui.viewModel.SettingsViewModel
data class SettingsNav( data class SettingsNav(
val onNavigateToBugReport: () -> Unit, val onNavigateToBugReport: () -> Unit,
val onNavigateToAbout: () -> Unit, val onNavigateToAbout: () -> Unit,
val onNavigateToMDMSettings: () -> Unit val onNavigateToMDMSettings: () -> Unit,
val onNavigateToManagedBy: () -> Unit,
) )
@Composable @Composable
@ -78,11 +79,11 @@ fun Settings(viewModel: SettingsViewModel) {
handler.openUri(Links.ADMIN_URL) handler.openUri(Links.ADMIN_URL)
}) })
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { viewModel.ipnActions.logout() }) { Button(onClick = { viewModel.ipnManager.logout() }) {
Text(text = stringResource(id = R.string.log_out)) Text(text = stringResource(id = R.string.log_out))
} }
} ?: run { } ?: run {
Button(onClick = { viewModel.ipnActions.login() }) { Button(onClick = { viewModel.ipnManager.login() }) {
Text(text = stringResource(id = R.string.log_in)) Text(text = stringResource(id = R.string.log_in))
} }
} }
@ -149,7 +150,7 @@ fun SettingsNavRow(setting: Setting) {
val enabled = setting.enabled.collectAsState().value val enabled = setting.enabled.collectAsState().value
Row(modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() }) { Row(modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() }) {
Text(text = stringResource(id = setting.titleRes)) Text(setting.title.getString())
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Text(text = txtVal, style = MaterialTheme.typography.bodyMedium) Text(text = txtVal, style = MaterialTheme.typography.bodyMedium)
} }
@ -163,7 +164,7 @@ fun SettingsSwitchRow(setting: Setting) {
val enabled = setting.enabled.collectAsState().value val enabled = setting.enabled.collectAsState().value
Row(modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() }, verticalAlignment = Alignment.CenterVertically) { Row(modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() }, verticalAlignment = Alignment.CenterVertically) {
Text(text = stringResource(id = setting.titleRes)) Text(setting.title.getString())
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Switch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled) Switch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled)
} }

@ -3,18 +3,33 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.BuildConfig
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.service.IpnActions import com.tailscale.ipn.mdm.StringSetting
import com.tailscale.ipn.ui.service.IpnModel import com.tailscale.ipn.ui.service.IpnManager
import com.tailscale.ipn.ui.service.set
import com.tailscale.ipn.ui.service.toggleCorpDNS import com.tailscale.ipn.ui.service.toggleCorpDNS
import com.tailscale.ipn.ui.view.SettingsNav import com.tailscale.ipn.ui.view.SettingsNav
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
enum class SettingType { NAV, SWITCH, NAV_WITH_TEXT } enum class SettingType { NAV, SWITCH, NAV_WITH_TEXT }
class ComposableStringFormatter(@StringRes val stringRes: Int, vararg val params: Any) {
@Composable
fun getString(): String = stringResource(id = stringRes, *params)
}
// Represents a bundle of settings values that should be grouped together uner a title
data class SettingBundle(val title: String? = null, val settings: List<Setting>)
// Represents a UI setting. // Represents a UI setting.
// title: The title of the setting // title: The title of the setting
// type: The type of setting // type: The type of setting
@ -28,23 +43,45 @@ enum class SettingType { NAV, SWITCH, NAV_WITH_TEXT }
// isOn and onToggle, while navigation settings should supply an onClick and an optional // isOn and onToggle, while navigation settings should supply an onClick and an optional
// value // value
data class Setting( data class Setting(
val titleRes: Int, val title: ComposableStringFormatter,
val type: SettingType, val type: SettingType,
val enabled: MutableStateFlow<Boolean> = MutableStateFlow(false), val enabled: StateFlow<Boolean> = MutableStateFlow(false),
val value: MutableStateFlow<String?>? = null, val value: StateFlow<String?>? = null,
val isOn: MutableStateFlow<Boolean?>? = null, val isOn: StateFlow<Boolean?>? = null,
val onClick: () -> Unit = {}, val onClick: () -> Unit = {},
val onToggle: (Boolean) -> Unit = {}) val onToggle: (Boolean) -> Unit = {}
) {
constructor(
titleRes: Int,
type: SettingType,
enabled: StateFlow<Boolean> = MutableStateFlow(false),
value: StateFlow<String?>? = null,
isOn: StateFlow<Boolean?>? = null,
onClick: () -> Unit = {},
onToggle: (Boolean) -> Unit = {}
) : this(
title = ComposableStringFormatter(titleRes),
type = type,
enabled = enabled,
value = value,
isOn = isOn,
onClick = onClick,
onToggle = onToggle
)
}
data class SettingBundle(val title: String? = null, val settings: List<Setting>)
class SettingsViewModel(val model: IpnModel, val ipnActions: IpnActions, val navigation: SettingsNav) : ViewModel() { class SettingsViewModel(
val ipnManager: IpnManager,
val navigation: SettingsNav
) : ViewModel() {
// The logged in user // The logged in user
val model = ipnManager.model
val mdmSettings = ipnManager.mdmSettings
val user = model.loggedInUser.value val user = model.loggedInUser.value
// Display name for the logged in user // Display name for the logged in user
val userName = user?.UserProfile?.DisplayName ?: ""
val tailnetName = user?.Name ?: ""
val isAdmin = model.netmap.value?.SelfNode?.isAdmin ?: false val isAdmin = model.netmap.value?.SelfNode?.isAdmin ?: false
val useDNSSetting = Setting( val useDNSSetting = Setting(
@ -61,21 +98,52 @@ class SettingsViewModel(val model: IpnModel, val ipnActions: IpnActions, val nav
viewModelScope.launch { viewModelScope.launch {
// Monitor our prefs for changes and update the displayed values accordingly // Monitor our prefs for changes and update the displayed values accordingly
model.prefs.collect { prefs -> model.prefs.collect { prefs ->
useDNSSetting.isOn?.value = prefs?.CorpDNS useDNSSetting.isOn?.set(prefs?.CorpDNS)
useDNSSetting.enabled?.value = prefs != null useDNSSetting.enabled.set(prefs != null)
} }
} }
} }
private val footerSettings: List<Setting> = listOfNotNull(
Setting(
titleRes = R.string.about,
SettingType.NAV,
onClick = { navigation.onNavigateToAbout() },
enabled = MutableStateFlow(true)
),
Setting(
titleRes = R.string.bug_report,
SettingType.NAV,
onClick = { navigation.onNavigateToBugReport() },
enabled = MutableStateFlow(true)
),
mdmSettings.get(StringSetting.ManagedByOrganizationName)?.let {
Setting(
ComposableStringFormatter(R.string.managed_by_orgName, it),
SettingType.NAV,
onClick = { navigation.onNavigateToManagedBy() },
enabled = MutableStateFlow(true)
)
},
if (BuildConfig.DEBUG) {
Setting(
titleRes = R.string.mdm_settings,
SettingType.NAV,
onClick = { navigation.onNavigateToMDMSettings() },
enabled = MutableStateFlow(true)
)
} else {
null
}
)
val settings: List<SettingBundle> = listOf( val settings: List<SettingBundle> = listOf(
SettingBundle(settings = listOf( SettingBundle(
useDNSSetting, settings = listOf(
)), useDNSSetting,
)
),
// General settings, always enabled // General settings, always enabled
SettingBundle(settings = listOf( SettingBundle(settings = footerSettings)
Setting(R.string.about, SettingType.NAV, onClick = { navigation.onNavigateToAbout() }, enabled = MutableStateFlow(true)),
Setting(R.string.bug_report, SettingType.NAV, onClick = { navigation.onNavigateToBugReport() }, enabled = MutableStateFlow(true)),
Setting(R.string.mdm_settings, SettingType.NAV, onClick = { navigation.onNavigateToMDMSettings() }, enabled = MutableStateFlow(true))
))
) )
} }

@ -48,6 +48,10 @@
<!-- Strings for MDM settings --> <!-- Strings for MDM settings -->
<string name="current_mdm_settings">Current MDM Settings</string> <string name="current_mdm_settings">Current MDM Settings</string>
<string name="mdm_settings">MDM Settings</string> <string name="mdm_settings">MDM Settings</string>
<string name="managed_by_orgName">Managed by %1$s</string>
<string name="managed_by_explainer">Your organization is managing Tailscale on this device. Some features might have been customized or hidden by your system administrator.</string>
<string name="managed_by_explainer_orgName">%1$s is managing Tailscale on this device. Some features might have been customized or hidden by your system administrator.</string>
<string name="open_support">Open Support</string>
<!-- State strings --> <!-- State strings -->
<string name="waiting">Waiting…</string> <string name="waiting">Waiting…</string>

Loading…
Cancel
Save