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:
parent
15f5781824
commit
ba7ebafe08
9 changed files with 539 additions and 4 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<AppDevice> = 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<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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
359
app/src/main/java/dev/kaizen/app/settings/SecuritySection.kt
Normal file
359
app/src/main/java/dev/kaizen/app/settings/SecuritySection.kt
Normal 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,28 @@
|
|||
<string name="login_error_rate_limited">Too many attempts. Please wait.</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 -->
|
||||
<string name="settings_title">Settings</string>
|
||||
<string name="settings_back">Back</string>
|
||||
|
|
|
|||
|
|
@ -126,6 +126,28 @@
|
|||
<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>
|
||||
|
||||
<!-- 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 -->
|
||||
<string name="settings_title">Einstellungen</string>
|
||||
<string name="settings_back">Zurück</string>
|
||||
|
|
|
|||
Loading…
Reference in a new issue