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 9 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.MainView
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.Settings
import com.tailscale.ipn.ui.view.SettingsNav
@ -58,7 +59,8 @@ class MainActivity : ComponentActivity() {
val settingsNav = SettingsNav(
onNavigateToBugReport = { navController.navigate("bugReport") },
onNavigateToAbout = { navController.navigate("about") },
onNavigateToMDMSettings = { navController.navigate("mdmSettings") }
onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
onNavigateToManagedBy = { navController.navigate("managedBy") }
)
composable("main") {
@ -68,7 +70,7 @@ class MainActivity : ComponentActivity() {
)
}
composable("settings") {
Settings(SettingsViewModel(manager.model, manager, settingsNav))
Settings(SettingsViewModel(manager, settingsNav))
}
composable("exitNodes") {
ExitNodePicker(ExitNodePickerViewModel(manager.model))
@ -94,6 +96,9 @@ class MainActivity : ComponentActivity() {
composable("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(
val onNavigateToBugReport: () -> Unit,
val onNavigateToAbout: () -> Unit,
val onNavigateToMDMSettings: () -> Unit
val onNavigateToMDMSettings: () -> Unit,
val onNavigateToManagedBy: () -> Unit,
)
@Composable
@ -78,11 +79,11 @@ fun Settings(viewModel: SettingsViewModel) {
handler.openUri(Links.ADMIN_URL)
})
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { viewModel.ipnActions.logout() }) {
Button(onClick = { viewModel.ipnManager.logout() }) {
Text(text = stringResource(id = R.string.log_out))
}
} ?: run {
Button(onClick = { viewModel.ipnActions.login() }) {
Button(onClick = { viewModel.ipnManager.login() }) {
Text(text = stringResource(id = R.string.log_in))
}
}
@ -149,7 +150,7 @@ fun SettingsNavRow(setting: Setting) {
val enabled = setting.enabled.collectAsState().value
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) {
Text(text = txtVal, style = MaterialTheme.typography.bodyMedium)
}
@ -163,7 +164,7 @@ fun SettingsSwitchRow(setting: Setting) {
val enabled = setting.enabled.collectAsState().value
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) {
Switch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled)
}

@ -3,18 +3,33 @@
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.viewModelScope
import com.tailscale.ipn.BuildConfig
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.service.IpnActions
import com.tailscale.ipn.ui.service.IpnModel
import com.tailscale.ipn.mdm.StringSetting
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.view.SettingsNav
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
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.
// title: The title of the 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
// value
data class Setting(
val titleRes: Int,
val title: ComposableStringFormatter,
val type: SettingType,
val enabled: MutableStateFlow<Boolean> = MutableStateFlow(false),
val value: MutableStateFlow<String?>? = null,
val isOn: MutableStateFlow<Boolean?>? = null,
val enabled: StateFlow<Boolean> = MutableStateFlow(false),
val value: StateFlow<String?>? = null,
val isOn: StateFlow<Boolean?>? = null,
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
val model = ipnManager.model
val mdmSettings = ipnManager.mdmSettings
val user = model.loggedInUser.value
// 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 useDNSSetting = Setting(
@ -61,21 +98,52 @@ class SettingsViewModel(val model: IpnModel, val ipnActions: IpnActions, val nav
viewModelScope.launch {
// Monitor our prefs for changes and update the displayed values accordingly
model.prefs.collect { prefs ->
useDNSSetting.isOn?.value = prefs?.CorpDNS
useDNSSetting.enabled?.value = prefs != null
useDNSSetting.isOn?.set(prefs?.CorpDNS)
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(
SettingBundle(settings = listOf(
SettingBundle(
settings = listOf(
useDNSSetting,
)),
)
),
// General settings, always enabled
SettingBundle(settings = listOf(
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))
))
SettingBundle(settings = footerSettings)
)
}

@ -48,6 +48,10 @@
<!-- Strings for MDM settings -->
<string name="current_mdm_settings">Current 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 -->
<string name="waiting">Waiting…</string>

Loading…
Cancel
Save