app 0.3.0: Login/Registrierung, Abmelden, 401 → Login; Previews + Landingpage v0.3.0
|
|
@ -31,8 +31,8 @@ android {
|
|||
applicationId = "dev.byteclaw.client"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 2
|
||||
versionName = "0.2.0"
|
||||
versionCode = 3
|
||||
versionName = "0.3.0"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import dev.byteclaw.client.ui.ChatScreen
|
|||
import dev.byteclaw.client.ui.ChatViewModel
|
||||
import dev.byteclaw.client.ui.SessionsScreen
|
||||
import dev.byteclaw.client.ui.SettingsScreen
|
||||
import dev.byteclaw.client.ui.LoginScreen
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private var voiceCallback: ((String) -> Unit)? = null
|
||||
|
|
@ -61,7 +62,16 @@ class MainActivity : ComponentActivity() {
|
|||
val vm: ChatViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val nav = rememberNavController()
|
||||
NavHost(nav, startDestination = if (state.settings.isConfigured) "chat" else "settings") {
|
||||
val loggedIn = state.settings.isConfigured
|
||||
androidx.compose.runtime.LaunchedEffect(loggedIn) {
|
||||
val target = if (loggedIn) "chat" else "login"
|
||||
if (nav.currentDestination?.route != target) nav.navigate(target) { popUpTo(0) { inclusive = true } }
|
||||
}
|
||||
NavHost(nav, startDestination = if (loggedIn) "chat" else "login") {
|
||||
composable("login") {
|
||||
LoginScreen(state, onAuthenticate = { u, n, p, r -> vm.authenticate(u, n, p, r) },
|
||||
onAdminToken = { u, t -> vm.useAdminToken(u, t) })
|
||||
}
|
||||
composable("chat") {
|
||||
androidx.compose.runtime.LaunchedEffect(state.settings) { if (state.settings.isConfigured) vm.loadProviders() }
|
||||
ChatScreen(
|
||||
|
|
@ -83,7 +93,7 @@ class MainActivity : ComponentActivity() {
|
|||
SettingsScreen(state, onSave = vm::saveSettings, onTest = { s, cb -> vm.testConnection(s, cb) }, onBack = {
|
||||
if (!nav.popBackStack()) nav.navigate("chat") { popUpTo("settings") { inclusive = true } }
|
||||
}, onLoadProviders = { vm.loadProviders() }, onLoadModels = { vm.loadModels(it) },
|
||||
onSelectProvider = { p, m -> vm.selectProvider(p, m) })
|
||||
onSelectProvider = { p, m -> vm.selectProvider(p, m) }, onLogout = vm::logout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ import okhttp3.WebSocketListener
|
|||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ApiException(msg: String) : IOException(msg)
|
||||
open class ApiException(msg: String, val code: Int = 0) : IOException(msg)
|
||||
|
||||
/** Token missing, expired or revoked: the app returns to the login screen. */
|
||||
class UnauthorizedException : ApiException("Sitzung abgelaufen – bitte erneut anmelden", 401)
|
||||
|
||||
/** REST + WebSocket client for the ByteClaw Go backend. TLS verification uses system defaults. */
|
||||
class ByteClawApi(private val settings: ServerSettings) {
|
||||
|
|
@ -33,7 +36,8 @@ class ByteClawApi(private val settings: ServerSettings) {
|
|||
private suspend fun call(req: Request): String = withContext(Dispatchers.IO) {
|
||||
http.newCall(req).execute().use { r: Response ->
|
||||
val body = r.body?.string().orEmpty()
|
||||
if (!r.isSuccessful) throw ApiException("HTTP ${r.code}: ${body.take(200)}")
|
||||
if (r.code == 401) throw UnauthorizedException()
|
||||
if (!r.isSuccessful) throw ApiException("HTTP ${r.code}: ${body.take(200)}", r.code)
|
||||
body
|
||||
}
|
||||
}
|
||||
|
|
@ -42,6 +46,13 @@ class ByteClawApi(private val settings: ServerSettings) {
|
|||
http.newCall(Request.Builder().url(url("/healthz")).build()).execute().use { it.isSuccessful }
|
||||
}
|
||||
|
||||
suspend fun me(): AuthUser = json.decodeFromString(call(Request.Builder().url(url("/api/auth/me")).build()))
|
||||
|
||||
/** Revokes the current token on the server. */
|
||||
suspend fun logout() {
|
||||
call(Request.Builder().url(url("/api/auth/logout")).post(ByteArray(0).toRequestBody(null)).build())
|
||||
}
|
||||
|
||||
suspend fun info(): ServerInfo = json.decodeFromString(call(Request.Builder().url(url("/api/info")).build()))
|
||||
|
||||
suspend fun sessions(): List<Session> = json.decodeFromString(call(Request.Builder().url(url("/api/sessions")).build()))
|
||||
|
|
@ -118,6 +129,21 @@ class ByteClawApi(private val settings: ServerSettings) {
|
|||
}
|
||||
|
||||
companion object {
|
||||
private val authJson = Json { ignoreUnknownKeys = true; explicitNulls = false }
|
||||
|
||||
/** POST /api/auth/login or /register without a bearer token. Errors carry German text. */
|
||||
suspend fun authenticate(serverUrl: String, username: String, password: String, register: Boolean): AuthResponse =
|
||||
withContext(Dispatchers.IO) {
|
||||
val path = if (register) "/api/auth/register" else "/api/auth/login"
|
||||
val body = authJson.encodeToString(Credentials.serializer(), Credentials(username.trim(), password)).toRequestBody(JSON)
|
||||
val req = Request.Builder().url((serverUrl.trim().trimEnd('/') + path).toHttpUrl()).post(body).build()
|
||||
shared.newCall(req).execute().use { r ->
|
||||
val text = r.body?.string().orEmpty()
|
||||
if (!r.isSuccessful) throw ApiException(AuthErrors.german(r.code, text), r.code)
|
||||
authJson.decodeFromString(AuthResponse.serializer(), text)
|
||||
}
|
||||
}
|
||||
|
||||
private val JSON = "application/json".toMediaType()
|
||||
private val shared = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
|
|
|
|||
|
|
@ -92,3 +92,33 @@ object ProviderPresets {
|
|||
|
||||
@Serializable
|
||||
data class RenameRequest(val title: String)
|
||||
|
||||
@Serializable
|
||||
data class Credentials(val username: String, val password: String)
|
||||
|
||||
@Serializable
|
||||
data class AuthUser(val id: Long = 0, val username: String = "", val admin: Boolean = false)
|
||||
|
||||
@Serializable
|
||||
data class AuthResponse(val token: String, val user: AuthUser = AuthUser())
|
||||
|
||||
/** Maps server auth errors to German UI text. */
|
||||
object AuthErrors {
|
||||
fun german(code: Int, body: String): String = when {
|
||||
code == 401 -> "Benutzername oder Passwort falsch"
|
||||
code == 409 -> "Benutzername ist bereits vergeben"
|
||||
code == 403 -> "Registrierung ist auf diesem Server deaktiviert"
|
||||
code == 429 -> "Zu viele Versuche – bitte später erneut probieren"
|
||||
code == 400 && body.contains("username") -> "Benutzername: 3–32 Zeichen (Buchstaben, Ziffern, . _ -)"
|
||||
code == 400 && body.contains("password") -> "Passwort: mindestens 8 Zeichen"
|
||||
else -> "Fehler (HTTP $code)"
|
||||
}
|
||||
|
||||
/** Client-side check before contacting the server; null = ok. */
|
||||
fun validate(username: String, password: String): String? {
|
||||
val u = username.trim()
|
||||
if (u.length !in 3..32 || !u.all { it.isLetterOrDigit() && it.code < 128 || it in "._-" }) return "Benutzername: 3–32 Zeichen (Buchstaben, Ziffern, . _ -)"
|
||||
if (password.length < 8) return "Passwort: mindestens 8 Zeichen"
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import android.content.SharedPreferences
|
|||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
data class ServerSettings(val serverUrl: String = "", val token: String = "") {
|
||||
data class ServerSettings(val serverUrl: String = "", val token: String = "", val username: String = "") {
|
||||
val isConfigured get() = serverUrl.isNotBlank() && token.isNotBlank()
|
||||
}
|
||||
|
||||
|
|
@ -22,14 +22,17 @@ class SettingsStore(context: Context) {
|
|||
fun load() = ServerSettings(
|
||||
serverUrl = prefs.getString(KEY_URL, "") ?: "",
|
||||
token = prefs.getString(KEY_TOKEN, "") ?: "",
|
||||
username = prefs.getString(KEY_USER, "") ?: "",
|
||||
)
|
||||
|
||||
fun save(s: ServerSettings) {
|
||||
prefs.edit().putString(KEY_URL, s.serverUrl.trim().trimEnd('/')).putString(KEY_TOKEN, s.token.trim()).apply()
|
||||
prefs.edit().putString(KEY_URL, s.serverUrl.trim().trimEnd('/')).putString(KEY_TOKEN, s.token.trim())
|
||||
.putString(KEY_USER, s.username.trim()).apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY_URL = "server_url"
|
||||
const val KEY_TOKEN = "token"
|
||||
const val KEY_USER = "username"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import androidx.lifecycle.AndroidViewModel
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import dev.byteclaw.client.data.ActiveProvider
|
||||
import dev.byteclaw.client.data.ByteClawApi
|
||||
import dev.byteclaw.client.data.AuthErrors
|
||||
import dev.byteclaw.client.data.UnauthorizedException
|
||||
import dev.byteclaw.client.data.ProviderInfo
|
||||
import dev.byteclaw.client.data.ProviderPresets
|
||||
import dev.byteclaw.client.data.ServerSettings
|
||||
|
|
@ -39,6 +41,9 @@ data class UiState(
|
|||
val sessionProvider: ActiveProvider? = null,
|
||||
/** null = unknown, true/false = last health check result. */
|
||||
val online: Boolean? = null,
|
||||
/** Login screen state. */
|
||||
val authBusy: Boolean = false,
|
||||
val authError: String? = null,
|
||||
) {
|
||||
/** What the current chat effectively uses: session override, else the global active provider. */
|
||||
val effectiveProvider: ActiveProvider? get() = sessionProvider ?: provider.active
|
||||
|
|
@ -73,6 +78,54 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
|
|||
_state.update { it.copy(settings = store.load(), error = null) }
|
||||
}
|
||||
|
||||
/** Login or registration; on success the token is stored encrypted and the chat opens. */
|
||||
fun authenticate(serverUrl: String, username: String, password: String, register: Boolean) {
|
||||
val urlErr = ByteClawApi.validateUrl(serverUrl)
|
||||
val err = urlErr ?: AuthErrors.validate(username, password)
|
||||
if (err != null) { _state.update { it.copy(authError = err) }; return }
|
||||
_state.update { it.copy(authBusy = true, authError = null) }
|
||||
viewModelScope.launch {
|
||||
runCatching { ByteClawApi.authenticate(serverUrl, username, password, register) }
|
||||
.onSuccess { r ->
|
||||
store.save(ServerSettings(serverUrl, r.token, r.user.username))
|
||||
_state.update { UiState(settings = store.load()) }
|
||||
}
|
||||
.onFailure { e -> _state.update { it.copy(authBusy = false, authError = e.message ?: "Verbindung fehlgeschlagen") } }
|
||||
}
|
||||
}
|
||||
|
||||
/** Back-compat: sign in with the server's static admin token. */
|
||||
fun useAdminToken(serverUrl: String, token: String) {
|
||||
val err = ByteClawApi.validateUrl(serverUrl) ?: if (token.trim().length < 16) "Token: mindestens 16 Zeichen" else null
|
||||
if (err != null) { _state.update { it.copy(authError = err) }; return }
|
||||
_state.update { it.copy(authBusy = true, authError = null) }
|
||||
viewModelScope.launch {
|
||||
val s = ServerSettings(serverUrl, token, "admin")
|
||||
runCatching { ByteClawApi(s).me() }
|
||||
.onSuccess { u -> store.save(s.copy(username = u.username)); _state.update { UiState(settings = store.load()) } }
|
||||
.onFailure { e -> _state.update { it.copy(authBusy = false, authError = if (e is UnauthorizedException) "Token ungültig" else e.message) } }
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
val s = _state.value.settings
|
||||
job?.cancel()
|
||||
viewModelScope.launch { runCatching { ByteClawApi(s).logout() } }
|
||||
clearSession(null)
|
||||
}
|
||||
|
||||
/** Drops the token (keeps URL + username for the login form). */
|
||||
private fun clearSession(reason: String?) {
|
||||
val s = _state.value.settings
|
||||
store.save(s.copy(token = ""))
|
||||
_state.update { UiState(settings = store.load(), authError = reason) }
|
||||
}
|
||||
|
||||
/** Central error handler: 401 sends the user back to the login screen. */
|
||||
private fun fail(e: Throwable) {
|
||||
if (e is UnauthorizedException) clearSession(e.message) else _state.update { it.copy(error = e.message) }
|
||||
}
|
||||
|
||||
fun testConnection(s: ServerSettings, onResult: (String) -> Unit) = viewModelScope.launch {
|
||||
val msg = runCatching {
|
||||
val i = ByteClawApi(s).info()
|
||||
|
|
@ -91,7 +144,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
|
|||
fun refreshSessions() = viewModelScope.launch {
|
||||
runCatching { api().sessions() }
|
||||
.onSuccess { list -> _state.update { it.copy(sessions = list) } }
|
||||
.onFailure { e -> _state.update { it.copy(error = e.message) } }
|
||||
.onFailure { e -> fail(e) }
|
||||
}
|
||||
|
||||
fun openSession(id: String) = viewModelScope.launch {
|
||||
|
|
@ -113,7 +166,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
|
|||
_state.update { it.copy(sessionProvider = ss.provider.takeIf { p -> p.isNotBlank() }?.let { p -> ActiveProvider(p, ss.model) }) }
|
||||
}
|
||||
}
|
||||
.onFailure { e -> _state.update { it.copy(error = e.message) } }
|
||||
.onFailure { e -> fail(e) }
|
||||
}
|
||||
|
||||
fun deleteSession(id: String) = viewModelScope.launch {
|
||||
|
|
@ -122,7 +175,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
|
|||
if (_state.value.sessionId == id) newChat()
|
||||
refreshSessions()
|
||||
}
|
||||
.onFailure { e -> _state.update { it.copy(error = e.message) } }
|
||||
.onFailure { e -> fail(e) }
|
||||
}
|
||||
|
||||
fun send(text: String) {
|
||||
|
|
@ -148,7 +201,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
|
|||
System.currentTimeMillis() - started)
|
||||
}
|
||||
"cancelled" -> markCancelled()
|
||||
"error" -> _state.update { s ->
|
||||
"error" -> if (ev.text == "HTTP 401") clearSession("Sitzung abgelaufen – bitte erneut anmelden") else _state.update { s ->
|
||||
s.copy(busy = false, error = ev.text, lines = s.lines.filterNot { it.role == "assistant" && it.text.isEmpty() })
|
||||
}
|
||||
}
|
||||
|
|
@ -171,7 +224,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
|
|||
if (t.isEmpty()) return@launch
|
||||
runCatching { api().renameSession(id, t) }
|
||||
.onSuccess { _state.update { s -> s.copy(sessions = s.sessions.map { if (it.id == id) it.copy(title = t) else it }) } }
|
||||
.onFailure { e -> _state.update { it.copy(error = e.message) } }
|
||||
.onFailure { e -> fail(e) }
|
||||
}
|
||||
|
||||
/** Stop button: cancel on the server (partial answer is saved there), fall back to closing the stream. */
|
||||
|
|
@ -208,14 +261,14 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
|
|||
viewModelScope.launch {
|
||||
runCatching { api().setSessionProvider(sid, name, model) }
|
||||
.onSuccess { a -> _state.update { it.copy(sessionProvider = a.takeIf { x -> x.provider.isNotBlank() }) } }
|
||||
.onFailure { e -> _state.update { it.copy(error = e.message) } }
|
||||
.onFailure { e -> fail(e) }
|
||||
}
|
||||
}
|
||||
|
||||
fun loadProviders() = viewModelScope.launch {
|
||||
runCatching { api().providers() }
|
||||
.onSuccess { r -> _state.update { it.copy(provider = it.provider.copy(providers = r.providers, active = r.active, status = null)) } }
|
||||
.onFailure { e -> _state.update { it.copy(provider = it.provider.copy(status = "Provider laden fehlgeschlagen: ${e.message}")) } }
|
||||
.onFailure { e -> if (e is UnauthorizedException) fail(e) else _state.update { it.copy(provider = it.provider.copy(status = "Provider laden fehlgeschlagen: ${e.message}")) } }
|
||||
}
|
||||
|
||||
fun loadModels(name: String) = viewModelScope.launch {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
package dev.byteclaw.client.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.byteclaw.client.data.ByteClawApi
|
||||
|
||||
/** Shown whenever no valid token is stored: sign in, sign up, or (advanced) use the static admin token. */
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
state: UiState,
|
||||
onAuthenticate: (url: String, user: String, pass: String, register: Boolean) -> Unit,
|
||||
onAdminToken: (url: String, token: String) -> Unit = { _, _ -> },
|
||||
initialRegister: Boolean = false,
|
||||
initialUser: String? = null,
|
||||
) {
|
||||
var register by remember { mutableStateOf(initialRegister) }
|
||||
var url by remember { mutableStateOf(state.settings.serverUrl) }
|
||||
var user by remember { mutableStateOf(initialUser ?: state.settings.username) }
|
||||
var pass by remember { mutableStateOf("") }
|
||||
var pass2 by remember { mutableStateOf("") }
|
||||
var adminMode by remember { mutableStateOf(false) }
|
||||
var token by remember { mutableStateOf("") }
|
||||
val urlError = if (url.isBlank()) null else ByteClawApi.validateUrl(url)
|
||||
val mismatch = register && pass2.isNotEmpty() && pass != pass2
|
||||
|
||||
Scaffold { pad ->
|
||||
Column(
|
||||
Modifier.padding(pad).fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text("ByteClaw", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold)
|
||||
Text(if (register) "Neues Konto erstellen" else "Bei deinem Server anmelden", style = MaterialTheme.typography.bodyLarge)
|
||||
if (!adminMode) {
|
||||
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
|
||||
SegmentedButton(!register, { register = false }, SegmentedButtonDefaults.itemShape(0, 2)) { Text("Anmelden") }
|
||||
SegmentedButton(register, { register = true }, SegmentedButtonDefaults.itemShape(1, 2)) { Text("Registrieren") }
|
||||
}
|
||||
}
|
||||
OutlinedTextField(url, { url = it }, Modifier.fillMaxWidth(), label = { Text("Server-URL") },
|
||||
placeholder = { Text("https://claw.example.org") }, singleLine = true,
|
||||
isError = urlError != null, supportingText = urlError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri))
|
||||
if (adminMode) {
|
||||
OutlinedTextField(token, { token = it }, Modifier.fillMaxWidth(), label = { Text("Admin-Token") }, singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
|
||||
} else {
|
||||
OutlinedTextField(user, { user = it }, Modifier.fillMaxWidth(), label = { Text("Benutzername") }, singleLine = true,
|
||||
supportingText = if (register) { { Text("3–32 Zeichen: Buchstaben, Ziffern, . _ -") } } else null)
|
||||
OutlinedTextField(pass, { pass = it }, Modifier.fillMaxWidth(), label = { Text("Passwort") }, singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
supportingText = if (register) { { Text("Mindestens 8 Zeichen") } } else null)
|
||||
if (register) {
|
||||
OutlinedTextField(pass2, { pass2 = it }, Modifier.fillMaxWidth(), label = { Text("Passwort wiederholen") }, singleLine = true,
|
||||
isError = mismatch, supportingText = if (mismatch) { { Text("Passwörter stimmen nicht überein") } } else null,
|
||||
visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
|
||||
}
|
||||
}
|
||||
state.authError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !state.authBusy && url.isNotBlank() && urlError == null &&
|
||||
(if (adminMode) token.isNotBlank() else user.isNotBlank() && pass.isNotEmpty() && (!register || pass == pass2)),
|
||||
onClick = { if (adminMode) onAdminToken(url, token) else onAuthenticate(url, user, pass, register) },
|
||||
) {
|
||||
if (state.authBusy) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
else Text(when { adminMode -> "Mit Token verbinden"; register -> "Konto erstellen"; else -> "Anmelden" })
|
||||
}
|
||||
TextButton(onClick = { adminMode = !adminMode }) {
|
||||
Text(if (adminMode) "Zurück zur Anmeldung" else "Stattdessen Admin-Token verwenden")
|
||||
}
|
||||
Text("Das Login-Token wird verschlüsselt (Android Keystore) gespeichert. Release-Builds erlauben nur HTTPS.",
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -270,11 +270,9 @@ fun SettingsScreen(
|
|||
onLoadModels: (String) -> Unit = {},
|
||||
onSelectProvider: (String, String) -> Unit = { _, _ -> },
|
||||
initialStatus: String? = null,
|
||||
onLogout: () -> Unit = {},
|
||||
) {
|
||||
var url by remember { mutableStateOf(state.settings.serverUrl) }
|
||||
var token by remember { mutableStateOf(state.settings.token) }
|
||||
var status by remember { mutableStateOf(initialStatus) }
|
||||
val urlError = if (url.isBlank()) null else ByteClawApi.validateUrl(url)
|
||||
LaunchedEffect(state.settings) { if (state.settings.isConfigured) onLoadProviders() }
|
||||
Scaffold(topBar = {
|
||||
TopAppBar(title = { Text("Einstellungen") },
|
||||
|
|
@ -282,28 +280,18 @@ fun SettingsScreen(
|
|||
}) { pad ->
|
||||
LazyColumn(Modifier.padding(pad).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
item {
|
||||
OutlinedTextField(url, { url = it }, Modifier.fillMaxWidth(), label = { Text("Server-URL") },
|
||||
placeholder = { Text("https://claw.example.org") }, singleLine = true,
|
||||
isError = urlError != null, supportingText = { urlError?.let { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri))
|
||||
}
|
||||
item {
|
||||
OutlinedTextField(token, { token = it }, Modifier.fillMaxWidth(), label = { Text("Token") }, singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
|
||||
}
|
||||
item {
|
||||
Text("Token und URL werden verschlüsselt (Android Keystore) gespeichert. Release-Builds erlauben nur HTTPS.",
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(enabled = urlError == null && url.isNotBlank() && token.isNotBlank(), onClick = {
|
||||
onSave(ServerSettings(url, token)); onBack()
|
||||
}) { Text("Speichern") }
|
||||
OutlinedButton(enabled = urlError == null && url.isNotBlank(), onClick = {
|
||||
status = "Teste…"; onTest(ServerSettings(url.trim().trimEnd('/'), token.trim())) { status = it }
|
||||
}) { Text("Verbindung testen") }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Konto", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Angemeldet als ${state.settings.username.ifBlank { "unbekannt" }}")
|
||||
Text("Server: ${state.settings.serverUrl}", style = MaterialTheme.typography.bodySmall)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = {
|
||||
status = "Teste…"; onTest(state.settings) { status = it }
|
||||
}) { Text("Verbindung testen") }
|
||||
Button(onClick = onLogout, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text("Abmelden") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
status?.let { item { Text(it) } }
|
||||
|
|
|
|||
36
android/app/src/test/java/dev/byteclaw/client/AuthTest.kt
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package dev.byteclaw.client
|
||||
|
||||
import dev.byteclaw.client.data.AuthErrors
|
||||
import dev.byteclaw.client.data.AuthResponse
|
||||
import dev.byteclaw.client.data.ServerSettings
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class AuthTest {
|
||||
@Test fun validation() {
|
||||
assertNotNull(AuthErrors.validate("ab", "longenough"))
|
||||
assertNotNull(AuthErrors.validate("a".repeat(33), "longenough"))
|
||||
assertNotNull(AuthErrors.validate("bad name", "longenough"))
|
||||
assertNotNull(AuthErrors.validate("bob", "short"))
|
||||
assertNull(AuthErrors.validate("crypto_1.x", "longenough"))
|
||||
}
|
||||
|
||||
@Test fun germanErrors() {
|
||||
assertEquals("Benutzername oder Passwort falsch", AuthErrors.german(401, ""))
|
||||
assertEquals("Benutzername ist bereits vergeben", AuthErrors.german(409, ""))
|
||||
assertTrue(AuthErrors.german(429, "").startsWith("Zu viele"))
|
||||
assertTrue(AuthErrors.german(400, "{\"error\":\"password must be\"}").startsWith("Passwort"))
|
||||
}
|
||||
|
||||
@Test fun parsesAuthResponse() {
|
||||
val r = Json { ignoreUnknownKeys = true }.decodeFromString(AuthResponse.serializer(),
|
||||
"""{"token":"bc_x","expires_at":"2026-12-01T00:00:00Z","user":{"id":2,"username":"alice"}}""")
|
||||
assertEquals("bc_x", r.token); assertEquals("alice", r.user.username)
|
||||
}
|
||||
|
||||
@Test fun configuredNeedsToken() {
|
||||
assertFalse(ServerSettings("https://x", "", "alice").isConfigured)
|
||||
assertTrue(ServerSettings("https://x", "bc_x", "alice").isConfigured)
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import dev.byteclaw.client.ui.ChatLine
|
|||
import dev.byteclaw.client.ui.ChatScreen
|
||||
import dev.byteclaw.client.ui.SessionsScreen
|
||||
import dev.byteclaw.client.ui.SettingsScreen
|
||||
import dev.byteclaw.client.ui.LoginScreen
|
||||
import dev.byteclaw.client.ui.UiState
|
||||
import dev.byteclaw.client.ui.ProviderUi
|
||||
import dev.byteclaw.client.data.ProviderInfo
|
||||
|
|
@ -35,7 +36,7 @@ class PreviewScreenshots(private val dark: Boolean) {
|
|||
maxPercentDifference = 0.1,
|
||||
)
|
||||
|
||||
private val settings = ServerSettings("https://demo.byteclaw.example", "demo-token-not-real")
|
||||
private val settings = ServerSettings("https://demo.byteclaw.example", "demo-token-not-real", "crypto")
|
||||
|
||||
private fun snap(name: String, content: @Composable () -> Unit) =
|
||||
paparazzi.snapshot(name + if (dark) "_dark" else "_light") { ByteClawTheme(dark = dark, content = content) }
|
||||
|
|
@ -149,4 +150,15 @@ class PreviewScreenshots(private val dark: Boolean) {
|
|||
onSave = {}, onTest = { _, _ -> }, onBack = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun login() = snap("login") {
|
||||
LoginScreen(state = UiState(settings = ServerSettings("https://demo.byteclaw.example", "", "crypto")), onAuthenticate = { _, _, _, _ -> })
|
||||
}
|
||||
|
||||
@Test fun loginRegister() = snap("login_register") {
|
||||
LoginScreen(
|
||||
state = UiState(settings = ServerSettings("https://demo.byteclaw.example"), authError = "Benutzername ist bereits vergeben"),
|
||||
onAuthenticate = { _, _, _, _ -> }, initialRegister = true, initialUser = "crypto",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 47 KiB |