diff --git a/CLAUDE.md b/CLAUDE.md index fa2f84b..4c0a63a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ db/ Room offline cache: KaizenDatabase, Entities, DAOs, haptics/ Phase-aware haptics net/ KaizenApi (HTTP), SecureStore (encrypted prefs), SessionViewModel, ServerConfig, StreamConsumer -settings/ SettingsScreen, SettingsViewModel, SettingsComponents +settings/ SettingsScreen, SettingsViewModel, SettingsComponents, SecuritySection ui/ theme/ Color (P3), Theme (multi-theme), Type, Oklab, Dither theme/themes/ AccentScheme, AmberTheme, BlauTheme, MonoTheme, AuroraTheme, ThemeRegistry @@ -100,12 +100,13 @@ Full spec: `DESIGN.md`. ### 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"). - Appearance persisted as `AppAppearance.name` ("Light"/"Dark"/"System"). - 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. - 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) diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt index fef36b9..46a1580 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -284,13 +284,14 @@ fun ChatScreen( forToggle: Boolean = false, onBiometricSuccess: () -> Unit, ) { + val biometricEnabled = settingsViewModel.biometricEnabled.value var activity: androidx.fragment.app.FragmentActivity? = null var ctx = context while (ctx is android.content.ContextWrapper) { if (ctx is androidx.fragment.app.FragmentActivity) { activity = ctx; break } 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) return } @@ -502,6 +503,7 @@ fun ChatScreen( AppScreen.Settings -> { SettingsScreen( viewModel = settingsViewModel, + config = session.config, onBack = { currentScreen = AppScreen.Chat }, modifier = modifier ) diff --git a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt index 74d91a3..d054d12 100644 --- a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt +++ b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt @@ -68,6 +68,16 @@ data class ConversationSummary( @Serializable private data class CreateConvoRequest(val title: String? = null) @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 = emptyList()) +@Serializable private data class ChangePasswordRequest(val currentPassword: String, val newPassword: String) + /** Attachment on a persisted message (image, audio, video, pdf, document, code). */ @Serializable data class Attachment( @@ -565,4 +575,63 @@ object KaizenApi { // 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 = + 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(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 = + 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(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") diff --git a/app/src/main/java/dev/kaizen/app/net/SecureStore.kt b/app/src/main/java/dev/kaizen/app/net/SecureStore.kt index 9d44aa0..d0edfc3 100644 --- a/app/src/main/java/dev/kaizen/app/net/SecureStore.kt +++ b/app/src/main/java/dev/kaizen/app/net/SecureStore.kt @@ -91,6 +91,9 @@ class SecureStore(context: Context) { fun loadLocale(): String? = prefs.getString(KEY_LOCALE, null) 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() { prefs.edit().clear().apply() } @@ -107,5 +110,6 @@ class SecureStore(context: Context) { const val KEY_APPEARANCE = "appearance" const val KEY_USER_NAME = "user_name" const val KEY_LOCALE = "locale" + const val KEY_BIOMETRIC_ENABLED = "biometric_enabled" } } diff --git a/app/src/main/java/dev/kaizen/app/settings/SecuritySection.kt b/app/src/main/java/dev/kaizen/app/settings/SecuritySection.kt new file mode 100644 index 0000000..695a9a0 --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/settings/SecuritySection.kt @@ -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(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(), + ) + } +} diff --git a/app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt b/app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt index b61e882..8d638f2 100644 --- a/app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt +++ b/app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt @@ -57,6 +57,7 @@ import dev.kaizen.app.chat.MeshBackground @Composable fun SettingsScreen( viewModel: SettingsViewModel, + config: dev.kaizen.app.net.ServerConfig? = null, onBack: () -> Unit, 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)) - 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) } + + SecuritySection(viewModel = viewModel, config = config) } } } diff --git a/app/src/main/java/dev/kaizen/app/settings/SettingsViewModel.kt b/app/src/main/java/dev/kaizen/app/settings/SettingsViewModel.kt index 110bfe5..40c09e5 100644 --- a/app/src/main/java/dev/kaizen/app/settings/SettingsViewModel.kt +++ b/app/src/main/java/dev/kaizen/app/settings/SettingsViewModel.kt @@ -1,11 +1,17 @@ package dev.kaizen.app.settings 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.ServerConfig import dev.kaizen.app.ui.theme.themes.KaizenThemeId import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch enum class AppAppearance { Light, Dark, System } @@ -69,4 +75,51 @@ class SettingsViewModel(private val store: SecureStore? = null) : ViewModel() { activity?.recreate() } } + + // ── Security ──────────────────────────────────────────────────────────── + + private val _biometricEnabled = MutableStateFlow(store?.loadBiometricEnabled() ?: true) + val biometricEnabled: StateFlow = _biometricEnabled.asStateFlow() + + private val _devices = MutableStateFlow>(emptyList()) + val devices: StateFlow> = _devices.asStateFlow() + + private val _devicesLoading = MutableStateFlow(false) + val devicesLoading: StateFlow = _devicesLoading.asStateFlow() + + private val _passwordChangeResult = MutableStateFlow(null) + val passwordChangeResult: StateFlow = _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 } } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 7e117b8..1571799 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -124,6 +124,28 @@ Too many attempts. Please wait. Connection failed. Check server address. + + SECURITY + Biometric Unlock + Unlock locked chats with fingerprint + No fingerprint enrolled on this device + Signed-in Devices + Manage app sessions and sign out remotely + This device + Last active: %1$s + Never used + Sign out + Change Password + Current password + New password + Confirm password + Change Password + Password changed + Current password is incorrect + At least 8 characters + Passwords don\'t match + Failed to change password + Settings Back diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bcda609..cb61786 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -126,6 +126,28 @@ Zu viele Versuche. Bitte kurz warten. Verbindung fehlgeschlagen. Server-Adresse prüfen. + + SICHERHEIT + Biometrische Entsperrung + Gesperrte Chats per Fingerabdruck entsperren + Kein Fingerabdruck auf dem Gerät eingerichtet + Angemeldete Geräte + App-Sitzungen verwalten und abmelden + Dieses Gerät + Zuletzt aktiv: %1$s + Nie verwendet + Abmelden + Passwort ändern + Aktuelles Passwort + Neues Passwort + Passwort bestätigen + Passwort ändern + Passwort geändert + Aktuelles Passwort ist falsch + Mindestens 8 Zeichen + Passwörter stimmen nicht überein + Fehler beim Ändern + Einstellungen Zurück