feat: security settings — devices, password change, biometric toggle

Security section in Settings with three cards:
- Biometric toggle (persisted in SecureStore, respected by unlock flow)
- Signed-in devices list with remote revoke (GET/DELETE /api/v1/auth/tokens)
- Password change form (POST /api/v1/auth/change-password)

requestBiometricOrPassword() now checks the biometric toggle — when
disabled, always falls back to password dialog.
This commit is contained in:
Bruno Deanoz 2026-06-22 13:44:01 +02:00
parent 15f5781824
commit ba7ebafe08
9 changed files with 539 additions and 4 deletions

View file

@ -43,7 +43,7 @@ db/ Room offline cache: KaizenDatabase, Entities, DAOs,
haptics/ Phase-aware haptics haptics/ Phase-aware haptics
net/ KaizenApi (HTTP), SecureStore (encrypted prefs), net/ KaizenApi (HTTP), SecureStore (encrypted prefs),
SessionViewModel, ServerConfig, StreamConsumer SessionViewModel, ServerConfig, StreamConsumer
settings/ SettingsScreen, SettingsViewModel, SettingsComponents settings/ SettingsScreen, SettingsViewModel, SettingsComponents, SecuritySection
ui/ ui/
theme/ Color (P3), Theme (multi-theme), Type, Oklab, Dither theme/ Color (P3), Theme (multi-theme), Type, Oklab, Dither
theme/themes/ AccentScheme, AmberTheme, BlauTheme, MonoTheme, AuroraTheme, ThemeRegistry theme/themes/ AccentScheme, AmberTheme, BlauTheme, MonoTheme, AuroraTheme, ThemeRegistry
@ -100,12 +100,13 @@ Full spec: `DESIGN.md`.
### Settings persistence + server sync ### Settings persistence + server sync
- **SettingsViewModel** takes `SecureStore` — theme, appearance, username, locale survive app restart. - **SettingsViewModel** takes `SecureStore` — theme, appearance, username, locale, biometric toggle survive app restart.
- Theme selection persisted as `KaizenThemeId.value` string ("amber"/"blue"/"mono"/"aurora"). - Theme selection persisted as `KaizenThemeId.value` string ("amber"/"blue"/"mono"/"aurora").
- Appearance persisted as `AppAppearance.name` ("Light"/"Dark"/"System"). - Appearance persisted as `AppAppearance.name` ("Light"/"Dark"/"System").
- On login + on each foreground: `KaizenApi.fetchMe()``MeResponse(name, email, defaultModel)` populates SettingsViewModel name/email. - On login + on each foreground: `KaizenApi.fetchMe()``MeResponse(name, email, defaultModel)` populates SettingsViewModel name/email.
- **Language selector** in Settings: System / Deutsch / English. Stored as `locale` in SecureStore. Note: currently only stores the preference; runtime locale switching (AppCompatDelegate.setApplicationLocales) needs wiring in MainActivity on next iteration. - **Language selector** in Settings: System / Deutsch / English. Stored as `locale` in SecureStore. Note: currently only stores the preference; runtime locale switching (AppCompatDelegate.setApplicationLocales) needs wiring in MainActivity on next iteration.
- No server-side theme storage — web uses localStorage, app uses SecureStore. Both are client-local. - No server-side theme storage — web uses localStorage, app uses SecureStore. Both are client-local.
- **Security section** (`SecuritySection.kt`): Biometric toggle (enabled/disabled, persisted in SecureStore), signed-in devices (list + remote revoke via `GET/DELETE /api/v1/auth/tokens`), password change (`POST /api/v1/auth/change-password`). `requestBiometricOrPassword()` in ChatScreen respects the biometric toggle — when disabled, always falls back to password dialog.
### Sentinel parsing — reasoning, sources, tools (added 2026-06-21) ### Sentinel parsing — reasoning, sources, tools (added 2026-06-21)

View file

@ -284,13 +284,14 @@ fun ChatScreen(
forToggle: Boolean = false, forToggle: Boolean = false,
onBiometricSuccess: () -> Unit, onBiometricSuccess: () -> Unit,
) { ) {
val biometricEnabled = settingsViewModel.biometricEnabled.value
var activity: androidx.fragment.app.FragmentActivity? = null var activity: androidx.fragment.app.FragmentActivity? = null
var ctx = context var ctx = context
while (ctx is android.content.ContextWrapper) { while (ctx is android.content.ContextWrapper) {
if (ctx is androidx.fragment.app.FragmentActivity) { activity = ctx; break } if (ctx is androidx.fragment.app.FragmentActivity) { activity = ctx; break }
ctx = ctx.baseContext ctx = ctx.baseContext
} }
if (activity == null || !dev.kaizen.app.auth.BiometricUnlock.isAvailable(activity)) { if (!biometricEnabled || activity == null || !dev.kaizen.app.auth.BiometricUnlock.isAvailable(activity)) {
showPasswordFallback(summary, forToggle) showPasswordFallback(summary, forToggle)
return return
} }
@ -502,6 +503,7 @@ fun ChatScreen(
AppScreen.Settings -> { AppScreen.Settings -> {
SettingsScreen( SettingsScreen(
viewModel = settingsViewModel, viewModel = settingsViewModel,
config = session.config,
onBack = { currentScreen = AppScreen.Chat }, onBack = { currentScreen = AppScreen.Chat },
modifier = modifier modifier = modifier
) )

View file

@ -68,6 +68,16 @@ data class ConversationSummary(
@Serializable private data class CreateConvoRequest(val title: String? = null) @Serializable private data class CreateConvoRequest(val title: String? = null)
@Serializable private data class CreatedConversation(val id: String) @Serializable private data class CreatedConversation(val id: String)
@Serializable data class AppDevice(
val id: String,
val name: String = "",
val lastUsedAt: String? = null,
val createdAt: String = "",
val isCurrent: Boolean = false,
)
@Serializable private data class AppDevicesResponse(val tokens: List<AppDevice> = emptyList())
@Serializable private data class ChangePasswordRequest(val currentPassword: String, val newPassword: String)
/** Attachment on a persisted message (image, audio, video, pdf, document, code). */ /** Attachment on a persisted message (image, audio, video, pdf, document, code). */
@Serializable @Serializable
data class Attachment( data class Attachment(
@ -565,4 +575,63 @@ object KaizenApi {
// best-effort — a failed prewarm just means the first real request pays the cold cost // best-effort — a failed prewarm just means the first real request pays the cold cost
} }
} }
/** GET /api/v1/auth/tokens — list the user's app devices/tokens. */
suspend fun fetchDevices(baseUrl: String, token: String): List<AppDevice> =
withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("$baseUrl/api/v1/auth/tokens")
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return@use emptyList()
json.decodeFromString<AppDevicesResponse>(resp.body!!.string()).tokens
}
} catch (e: Exception) {
Log.w(TAG, "fetchDevices: ${e.javaClass.simpleName}: ${e.message}")
emptyList()
}
}
/** DELETE /api/v1/auth/tokens/[id] — revoke a device token (remote logout). */
suspend fun revokeDevice(baseUrl: String, token: String, deviceId: String): Boolean =
withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("$baseUrl/api/v1/auth/tokens/$deviceId")
.delete()
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { it.isSuccessful }
} catch (e: Exception) {
Log.w(TAG, "revokeDevice: ${e.javaClass.simpleName}: ${e.message}")
false
}
}
/** POST /api/v1/auth/change-password — change the user's password. */
suspend fun changePassword(baseUrl: String, token: String, currentPassword: String, newPassword: String): FetchResult<Unit> =
withContext(Dispatchers.IO) {
val body = json.encodeToString(ChangePasswordRequest(currentPassword, newPassword))
val req = Request.Builder()
.url("$baseUrl/api/v1/auth/change-password")
.post(body.toRequestBody(jsonMedia))
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { resp ->
if (resp.isSuccessful) FetchResult.Ok(Unit)
else {
val errBody = resp.body?.string() ?: ""
val error = try { json.decodeFromString<ErrorBody>(errBody).error } catch (_: Exception) { "unknown" }
FetchResult.Fail(error)
}
}
} catch (e: Exception) {
FetchResult.Fail(e.message ?: "network_error")
}
}
} }
@Serializable private data class ErrorBody(val error: String = "unknown")

View file

@ -91,6 +91,9 @@ class SecureStore(context: Context) {
fun loadLocale(): String? = prefs.getString(KEY_LOCALE, null) fun loadLocale(): String? = prefs.getString(KEY_LOCALE, null)
fun saveLocale(locale: String) { prefs.edit().putString(KEY_LOCALE, locale).apply() } fun saveLocale(locale: String) { prefs.edit().putString(KEY_LOCALE, locale).apply() }
fun loadBiometricEnabled(): Boolean = prefs.getBoolean(KEY_BIOMETRIC_ENABLED, true)
fun saveBiometricEnabled(enabled: Boolean) { prefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, enabled).apply() }
fun clear() { fun clear() {
prefs.edit().clear().apply() prefs.edit().clear().apply()
} }
@ -107,5 +110,6 @@ class SecureStore(context: Context) {
const val KEY_APPEARANCE = "appearance" const val KEY_APPEARANCE = "appearance"
const val KEY_USER_NAME = "user_name" const val KEY_USER_NAME = "user_name"
const val KEY_LOCALE = "locale" const val KEY_LOCALE = "locale"
const val KEY_BIOMETRIC_ENABLED = "biometric_enabled"
} }
} }

View file

@ -0,0 +1,359 @@
package dev.kaizen.app.settings
import androidx.biometric.BiometricManager
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Fingerprint
import androidx.compose.material.icons.rounded.Key
import androidx.compose.material.icons.rounded.PhoneAndroid
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.kaizen.app.R
import dev.kaizen.app.net.AppDevice
import dev.kaizen.app.net.ServerConfig
import dev.kaizen.app.ui.shape.KaizenShapes
import dev.kaizen.app.ui.theme.LocalKaizenAccent
@Composable
fun SecuritySection(
viewModel: SettingsViewModel,
config: ServerConfig?,
) {
val cs = MaterialTheme.colorScheme
val accent = LocalKaizenAccent.current
val context = LocalContext.current
val biometricEnabled by viewModel.biometricEnabled.collectAsState()
val devices by viewModel.devices.collectAsState()
val devicesLoading by viewModel.devicesLoading.collectAsState()
val passwordResult by viewModel.passwordChangeResult.collectAsState()
val biometricAvailable = remember {
val bm = BiometricManager.from(context)
bm.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
}
val biometricNotEnrolled = remember {
val bm = BiometricManager.from(context)
bm.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED
}
LaunchedEffect(config) {
config?.let { viewModel.loadDevices(it) }
}
Text(
stringResource(R.string.settings_section_security),
color = cs.onSurfaceVariant.copy(alpha = 0.6f),
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 8.dp, bottom = 8.dp),
)
// Biometric toggle
SettingsCard(modifier = Modifier.padding(bottom = 16.dp)) {
SettingsOptionRow(
title = stringResource(R.string.settings_biometric_title),
description = if (biometricNotEnrolled) stringResource(R.string.settings_biometric_not_enrolled)
else stringResource(R.string.settings_biometric_desc),
icon = Icons.Rounded.Fingerprint,
) {
Switch(
checked = biometricEnabled && biometricAvailable,
onCheckedChange = { viewModel.setBiometricEnabled(it) },
enabled = biometricAvailable,
colors = SwitchDefaults.colors(
checkedThumbColor = accent.onPrimary,
checkedTrackColor = accent.primary,
),
)
}
}
// Devices
SettingsCard(modifier = Modifier.padding(bottom = 16.dp)) {
SettingsOptionRow(
title = stringResource(R.string.settings_devices_title),
description = stringResource(R.string.settings_devices_desc),
icon = Icons.Rounded.PhoneAndroid,
) {
if (devicesLoading) {
CircularProgressIndicator(
color = accent.primary,
strokeWidth = 2.dp,
modifier = Modifier.size(20.dp),
)
} else {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
devices.forEach { device ->
DeviceRow(
device = device,
onRevoke = { config?.let { viewModel.revokeDevice(it, device.id) } },
)
}
if (devices.isEmpty()) {
Text(
"",
color = cs.onSurfaceVariant.copy(alpha = 0.4f),
fontSize = 13.sp,
)
}
}
}
}
}
// Change password
SettingsCard(modifier = Modifier.padding(bottom = 24.dp)) {
SettingsOptionRow(
title = stringResource(R.string.settings_password_title),
description = "",
icon = Icons.Rounded.Key,
) {
PasswordChangeForm(
onSubmit = { current, new_ ->
config?.let { viewModel.changePassword(it, current, new_) }
},
result = passwordResult,
onClearResult = { viewModel.clearPasswordResult() },
)
}
}
}
@Composable
private fun DeviceRow(device: AppDevice, onRevoke: () -> Unit) {
val cs = MaterialTheme.colorScheme
val accent = LocalKaizenAccent.current
val locale = java.util.Locale.getDefault()
val lastUsedText = remember(device.lastUsedAt) {
if (device.lastUsedAt != null) {
try {
val date = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", locale).parse(device.lastUsedAt)
?: java.util.Date(device.lastUsedAt.toLongOrNull() ?: 0)
java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM, locale).format(date)
} catch (_: Exception) {
device.lastUsedAt
}
} else null
}
Row(
Modifier
.fillMaxWidth()
.clip(KaizenShapes.md)
.background(cs.surface.copy(alpha = 0.4f))
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
device.name.ifBlank { "App" },
color = cs.onBackground,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
)
if (device.isCurrent) {
Spacer(Modifier.width(6.dp))
Text(
stringResource(R.string.settings_device_current),
color = accent.primary,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
Text(
if (lastUsedText != null) stringResource(R.string.settings_device_last_used, lastUsedText)
else stringResource(R.string.settings_device_never_used),
color = cs.onSurfaceVariant.copy(alpha = 0.6f),
fontSize = 11.sp,
)
}
if (!device.isCurrent) {
Text(
stringResource(R.string.settings_device_revoke),
color = Color(0xFFEF4444),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier
.clip(KaizenShapes.sm)
.clickable { onRevoke() }
.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
}
}
@Composable
private fun PasswordChangeForm(
onSubmit: (currentPassword: String, newPassword: String) -> Unit,
result: String?,
onClearResult: () -> Unit,
) {
val cs = MaterialTheme.colorScheme
val accent = LocalKaizenAccent.current
var current by remember { mutableStateOf("") }
var new_ by remember { mutableStateOf("") }
var confirm by remember { mutableStateOf("") }
var localError by remember { mutableStateOf<String?>(null) }
val mismatchMsg = stringResource(R.string.settings_password_error_mismatch)
val shortMsg = stringResource(R.string.settings_password_error_short)
val successMsg = stringResource(R.string.settings_password_success)
val wrongMsg = stringResource(R.string.settings_password_error_wrong)
val unknownMsg = stringResource(R.string.settings_password_error_unknown)
LaunchedEffect(result) {
when (result) {
"ok" -> {
current = ""; new_ = ""; confirm = ""; localError = null
kotlinx.coroutines.delay(2000)
onClearResult()
}
"invalid_password" -> { localError = wrongMsg; onClearResult() }
"password_too_short" -> { localError = shortMsg; onClearResult() }
null -> {}
else -> { localError = unknownMsg; onClearResult() }
}
}
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
PasswordField(value = current, onValueChange = { current = it; localError = null }, placeholder = stringResource(R.string.settings_password_current))
PasswordField(value = new_, onValueChange = { new_ = it; localError = null }, placeholder = stringResource(R.string.settings_password_new))
PasswordField(value = confirm, onValueChange = { confirm = it; localError = null }, placeholder = stringResource(R.string.settings_password_confirm), imeAction = ImeAction.Done, onImeDone = {
if (new_.length >= 8 && new_ == confirm && current.isNotEmpty()) onSubmit(current, new_)
})
if (localError != null) {
Text(localError!!, color = Color(0xFFEF4444), fontSize = 12.sp)
}
if (result == "ok") {
Text(successMsg, color = accent.primary, fontSize = 12.sp, fontWeight = FontWeight.Medium)
}
val canSubmit = current.isNotEmpty() && new_.length >= 8 && new_ == confirm
Box(
Modifier
.fillMaxWidth()
.heightIn(min = 40.dp)
.shadow(elevation = if (canSubmit) 4.dp else 2.dp, shape = KaizenShapes.md, clip = false)
.clip(KaizenShapes.md)
.background(
if (canSubmit) Brush.linearGradient(listOf(accent.primary, accent.primaryDeep))
else Brush.linearGradient(listOf(cs.surface, cs.surface))
)
.clickable(enabled = canSubmit) {
if (new_ != confirm) { localError = mismatchMsg; return@clickable }
if (new_.length < 8) { localError = shortMsg; return@clickable }
onSubmit(current, new_)
}
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center,
) {
Text(
stringResource(R.string.settings_password_submit),
color = if (canSubmit) accent.onPrimary else cs.onSurfaceVariant,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
}
@Composable
private fun PasswordField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
imeAction: ImeAction = ImeAction.Next,
onImeDone: () -> Unit = {},
) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
val accent = LocalKaizenAccent.current
val bg = if (isDark) {
Brush.verticalGradient(listOf(Color(0xFF1E293B).copy(alpha = 0.60f), Color(0xFF0F172A).copy(alpha = 0.72f)))
} else {
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.85f), Color(0xFFEEF2F6).copy(alpha = 0.75f)))
}
val border = if (isDark) {
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.14f), Color.White.copy(alpha = 0.03f)))
} else {
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.50f), Color.Black.copy(alpha = 0.05f)))
}
Box(
Modifier
.fillMaxWidth()
.clip(KaizenShapes.md)
.background(bg)
.border(1.dp, border, KaizenShapes.md)
.heightIn(min = 44.dp)
.padding(horizontal = 14.dp, vertical = 12.dp),
contentAlignment = Alignment.CenterStart,
) {
if (value.isEmpty()) {
Text(placeholder, color = cs.onSurfaceVariant.copy(alpha = 0.5f), fontSize = 14.sp)
}
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = TextStyle(color = cs.onBackground, fontSize = 14.sp),
cursorBrush = SolidColor(accent.primary),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),
keyboardActions = KeyboardActions(onDone = { onImeDone() }),
modifier = Modifier.fillMaxWidth(),
)
}
}

View file

@ -57,6 +57,7 @@ import dev.kaizen.app.chat.MeshBackground
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
config: dev.kaizen.app.net.ServerConfig? = null,
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@ -149,9 +150,11 @@ fun SettingsScreen(
Text(stringResource(R.string.settings_section_ai), color = cs.onSurfaceVariant.copy(alpha = 0.6f), fontSize = 12.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp, bottom = 8.dp)) Text(stringResource(R.string.settings_section_ai), color = cs.onSurfaceVariant.copy(alpha = 0.6f), fontSize = 12.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp, bottom = 8.dp))
SettingsCard(modifier = Modifier.padding(bottom = 32.dp)) { SettingsCard(modifier = Modifier.padding(bottom = 24.dp)) {
SettingsOptionRow(title = stringResource(R.string.settings_ai_title), description = "", icon = Icons.Rounded.AutoAwesome) SettingsOptionRow(title = stringResource(R.string.settings_ai_title), description = "", icon = Icons.Rounded.AutoAwesome)
} }
SecuritySection(viewModel = viewModel, config = config)
} }
} }
} }

View file

@ -1,11 +1,17 @@
package dev.kaizen.app.settings package dev.kaizen.app.settings
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.kaizen.app.net.AppDevice
import dev.kaizen.app.net.FetchResult
import dev.kaizen.app.net.KaizenApi
import dev.kaizen.app.net.SecureStore import dev.kaizen.app.net.SecureStore
import dev.kaizen.app.net.ServerConfig
import dev.kaizen.app.ui.theme.themes.KaizenThemeId import dev.kaizen.app.ui.theme.themes.KaizenThemeId
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
enum class AppAppearance { Light, Dark, System } enum class AppAppearance { Light, Dark, System }
@ -69,4 +75,51 @@ class SettingsViewModel(private val store: SecureStore? = null) : ViewModel() {
activity?.recreate() activity?.recreate()
} }
} }
// ── Security ────────────────────────────────────────────────────────────
private val _biometricEnabled = MutableStateFlow(store?.loadBiometricEnabled() ?: true)
val biometricEnabled: StateFlow<Boolean> = _biometricEnabled.asStateFlow()
private val _devices = MutableStateFlow<List<AppDevice>>(emptyList())
val devices: StateFlow<List<AppDevice>> = _devices.asStateFlow()
private val _devicesLoading = MutableStateFlow(false)
val devicesLoading: StateFlow<Boolean> = _devicesLoading.asStateFlow()
private val _passwordChangeResult = MutableStateFlow<String?>(null)
val passwordChangeResult: StateFlow<String?> = _passwordChangeResult.asStateFlow()
fun setBiometricEnabled(enabled: Boolean) {
_biometricEnabled.value = enabled
store?.saveBiometricEnabled(enabled)
}
fun loadDevices(config: ServerConfig) {
viewModelScope.launch {
_devicesLoading.value = true
_devices.value = KaizenApi.fetchDevices(config.baseUrl, config.token)
_devicesLoading.value = false
}
}
fun revokeDevice(config: ServerConfig, deviceId: String) {
viewModelScope.launch {
if (KaizenApi.revokeDevice(config.baseUrl, config.token, deviceId)) {
_devices.value = _devices.value.filter { it.id != deviceId }
}
}
}
fun changePassword(config: ServerConfig, currentPassword: String, newPassword: String) {
viewModelScope.launch {
_passwordChangeResult.value = null
when (val r = KaizenApi.changePassword(config.baseUrl, config.token, currentPassword, newPassword)) {
is FetchResult.Ok -> _passwordChangeResult.value = "ok"
is FetchResult.Fail -> _passwordChangeResult.value = r.reason
}
}
}
fun clearPasswordResult() { _passwordChangeResult.value = null }
} }

View file

@ -124,6 +124,28 @@
<string name="login_error_rate_limited">Too many attempts. Please wait.</string> <string name="login_error_rate_limited">Too many attempts. Please wait.</string>
<string name="login_error_connection">Connection failed. Check server address.</string> <string name="login_error_connection">Connection failed. Check server address.</string>
<!-- Security settings -->
<string name="settings_section_security">SECURITY</string>
<string name="settings_biometric_title">Biometric Unlock</string>
<string name="settings_biometric_desc">Unlock locked chats with fingerprint</string>
<string name="settings_biometric_not_enrolled">No fingerprint enrolled on this device</string>
<string name="settings_devices_title">Signed-in Devices</string>
<string name="settings_devices_desc">Manage app sessions and sign out remotely</string>
<string name="settings_device_current">This device</string>
<string name="settings_device_last_used">Last active: %1$s</string>
<string name="settings_device_never_used">Never used</string>
<string name="settings_device_revoke">Sign out</string>
<string name="settings_password_title">Change Password</string>
<string name="settings_password_current">Current password</string>
<string name="settings_password_new">New password</string>
<string name="settings_password_confirm">Confirm password</string>
<string name="settings_password_submit">Change Password</string>
<string name="settings_password_success">Password changed</string>
<string name="settings_password_error_wrong">Current password is incorrect</string>
<string name="settings_password_error_short">At least 8 characters</string>
<string name="settings_password_error_mismatch">Passwords don\'t match</string>
<string name="settings_password_error_unknown">Failed to change password</string>
<!-- Settings --> <!-- Settings -->
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>
<string name="settings_back">Back</string> <string name="settings_back">Back</string>

View file

@ -126,6 +126,28 @@
<string name="login_error_rate_limited">Zu viele Versuche. Bitte kurz warten.</string> <string name="login_error_rate_limited">Zu viele Versuche. Bitte kurz warten.</string>
<string name="login_error_connection">Verbindung fehlgeschlagen. Server-Adresse prüfen.</string> <string name="login_error_connection">Verbindung fehlgeschlagen. Server-Adresse prüfen.</string>
<!-- Security settings -->
<string name="settings_section_security">SICHERHEIT</string>
<string name="settings_biometric_title">Biometrische Entsperrung</string>
<string name="settings_biometric_desc">Gesperrte Chats per Fingerabdruck entsperren</string>
<string name="settings_biometric_not_enrolled">Kein Fingerabdruck auf dem Gerät eingerichtet</string>
<string name="settings_devices_title">Angemeldete Geräte</string>
<string name="settings_devices_desc">App-Sitzungen verwalten und abmelden</string>
<string name="settings_device_current">Dieses Gerät</string>
<string name="settings_device_last_used">Zuletzt aktiv: %1$s</string>
<string name="settings_device_never_used">Nie verwendet</string>
<string name="settings_device_revoke">Abmelden</string>
<string name="settings_password_title">Passwort ändern</string>
<string name="settings_password_current">Aktuelles Passwort</string>
<string name="settings_password_new">Neues Passwort</string>
<string name="settings_password_confirm">Passwort bestätigen</string>
<string name="settings_password_submit">Passwort ändern</string>
<string name="settings_password_success">Passwort geändert</string>
<string name="settings_password_error_wrong">Aktuelles Passwort ist falsch</string>
<string name="settings_password_error_short">Mindestens 8 Zeichen</string>
<string name="settings_password_error_mismatch">Passwörter stimmen nicht überein</string>
<string name="settings_password_error_unknown">Fehler beim Ändern</string>
<!-- Settings --> <!-- Settings -->
<string name="settings_title">Einstellungen</string> <string name="settings_title">Einstellungen</string>
<string name="settings_back">Zurück</string> <string name="settings_back">Zurück</string>