diff --git a/README.md b/README.md index 243aba5..3723a45 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Abhängigkeiten des Backends: `gopkg.in/yaml.v3`, `modernc.org/sqlite`, `github. ## Sichere Defaults -- Start verweigert ohne `auth_token` (mind. 16 Zeichen); Vergleich in konstanter Zeit. +- Konten: `POST /api/auth/register|login|logout`, `GET /api/auth/me`; Passwörter als bcrypt-Hash, Tokens zufällig + gehasht gespeichert, 30 Tage gültig, Logout widerruft. Sessions sind pro Nutzer getrennt. Login-Rate-Limit pro IP. `allow_registration` (Standard an). Optionaler statischer `auth_token` (mind. 16 Zeichen) = Admin, sieht alle Sessions; Vergleich in konstanter Zeit. - Lauscht standardmäßig nur auf `127.0.0.1:8787`. - Browser-Origins: nur gleicher Host oder explizit in `allowed_origins`; `*` muss bewusst gesetzt werden. Native Clients (ohne `Origin`-Header) brauchen trotzdem das Token. - TLS-Zertifikatsprüfung ist überall an (LLM-Provider, `web_fetch`); es gibt keinen Schalter zum Abschalten. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 78c3d76..39e2a6b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 { diff --git a/android/app/src/main/java/dev/byteclaw/client/MainActivity.kt b/android/app/src/main/java/dev/byteclaw/client/MainActivity.kt index 9f7346b..7b9d59a 100644 --- a/android/app/src/main/java/dev/byteclaw/client/MainActivity.kt +++ b/android/app/src/main/java/dev/byteclaw/client/MainActivity.kt @@ -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) } } } diff --git a/android/app/src/main/java/dev/byteclaw/client/data/ByteClawApi.kt b/android/app/src/main/java/dev/byteclaw/client/data/ByteClawApi.kt index 4ea46a0..b3c89ac 100644 --- a/android/app/src/main/java/dev/byteclaw/client/data/ByteClawApi.kt +++ b/android/app/src/main/java/dev/byteclaw/client/data/ByteClawApi.kt @@ -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 = 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) diff --git a/android/app/src/main/java/dev/byteclaw/client/data/Models.kt b/android/app/src/main/java/dev/byteclaw/client/data/Models.kt index c9aebf0..399ac78 100644 --- a/android/app/src/main/java/dev/byteclaw/client/data/Models.kt +++ b/android/app/src/main/java/dev/byteclaw/client/data/Models.kt @@ -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 + } +} diff --git a/android/app/src/main/java/dev/byteclaw/client/data/SettingsStore.kt b/android/app/src/main/java/dev/byteclaw/client/data/SettingsStore.kt index df170e4..8105c73 100644 --- a/android/app/src/main/java/dev/byteclaw/client/data/SettingsStore.kt +++ b/android/app/src/main/java/dev/byteclaw/client/data/SettingsStore.kt @@ -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" } } diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt b/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt index 398323a..780339a 100644 --- a/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt +++ b/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt @@ -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 { diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/LoginScreen.kt b/android/app/src/main/java/dev/byteclaw/client/ui/LoginScreen.kt new file mode 100644 index 0000000..5580298 --- /dev/null +++ b/android/app/src/main/java/dev/byteclaw/client/ui/LoginScreen.kt @@ -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) + } + } +} diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt b/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt index 5d52401..74e7c73 100644 --- a/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt +++ b/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt @@ -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) } } diff --git a/android/app/src/test/java/dev/byteclaw/client/AuthTest.kt b/android/app/src/test/java/dev/byteclaw/client/AuthTest.kt new file mode 100644 index 0000000..521229c --- /dev/null +++ b/android/app/src/test/java/dev/byteclaw/client/AuthTest.kt @@ -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) + } +} diff --git a/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt b/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt index 37d3920..65a1d81 100644 --- a/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt +++ b/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt @@ -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", + ) + } } diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_loginRegister[0]_login_register_light.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_loginRegister[0]_login_register_light.png new file mode 100644 index 0000000..93d8bb0 Binary files /dev/null and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_loginRegister[0]_login_register_light.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_loginRegister[1]_login_register_dark.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_loginRegister[1]_login_register_dark.png new file mode 100644 index 0000000..62c1ef8 Binary files /dev/null and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_loginRegister[1]_login_register_dark.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_login[0]_login_light.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_login[0]_login_light.png new file mode 100644 index 0000000..78976a9 Binary files /dev/null and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_login[0]_login_light.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_login[1]_login_dark.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_login[1]_login_dark.png new file mode 100644 index 0000000..c8bc193 Binary files /dev/null and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_login[1]_login_dark.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[0]_providers_light.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[0]_providers_light.png index f50dc3c..48101b0 100644 Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[0]_providers_light.png and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[0]_providers_light.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[1]_providers_dark.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[1]_providers_dark.png index 3e99d6c..87d9bd8 100644 Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[1]_providers_dark.png and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[1]_providers_dark.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[0]_settings_light.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[0]_settings_light.png index e93b138..9058377 100644 Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[0]_settings_light.png and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[0]_settings_light.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[1]_settings_dark.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[1]_settings_dark.png index d6e7320..0c61a8a 100644 Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[1]_settings_dark.png and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[1]_settings_dark.png differ diff --git a/preview/login_dark.png b/preview/login_dark.png new file mode 100644 index 0000000..c8bc193 Binary files /dev/null and b/preview/login_dark.png differ diff --git a/preview/login_light.png b/preview/login_light.png new file mode 100644 index 0000000..78976a9 Binary files /dev/null and b/preview/login_light.png differ diff --git a/preview/login_register_dark.png b/preview/login_register_dark.png new file mode 100644 index 0000000..62c1ef8 Binary files /dev/null and b/preview/login_register_dark.png differ diff --git a/preview/login_register_light.png b/preview/login_register_light.png new file mode 100644 index 0000000..93d8bb0 Binary files /dev/null and b/preview/login_register_light.png differ diff --git a/preview/providers_dark.png b/preview/providers_dark.png index 3e99d6c..87d9bd8 100644 Binary files a/preview/providers_dark.png and b/preview/providers_dark.png differ diff --git a/preview/providers_light.png b/preview/providers_light.png index f50dc3c..48101b0 100644 Binary files a/preview/providers_light.png and b/preview/providers_light.png differ diff --git a/preview/settings_dark.png b/preview/settings_dark.png index d6e7320..0c61a8a 100644 Binary files a/preview/settings_dark.png and b/preview/settings_dark.png differ diff --git a/preview/settings_light.png b/preview/settings_light.png index e93b138..9058377 100644 Binary files a/preview/settings_light.png and b/preview/settings_light.png differ diff --git a/site/_headers b/site/_headers index 6a7ba43..cc11c9f 100644 --- a/site/_headers +++ b/site/_headers @@ -1,3 +1,3 @@ /downloads/*.apk Content-Type: application/vnd.android.package-archive - Content-Disposition: attachment; filename="byteclaw-0.2.0.apk" + Content-Disposition: attachment; filename="byteclaw-0.3.0.apk" diff --git a/site/assets/chat_dark.png b/site/assets/chat_dark.png index 5f6be42..280082a 100644 Binary files a/site/assets/chat_dark.png and b/site/assets/chat_dark.png differ diff --git a/site/assets/chat_dark.webp b/site/assets/chat_dark.webp index 010aa2f..79ac3a2 100644 Binary files a/site/assets/chat_dark.webp and b/site/assets/chat_dark.webp differ diff --git a/site/assets/chat_markdown_dark.png b/site/assets/chat_markdown_dark.png index 6bcd98b..bee7d6a 100644 Binary files a/site/assets/chat_markdown_dark.png and b/site/assets/chat_markdown_dark.png differ diff --git a/site/assets/chat_markdown_light.png b/site/assets/chat_markdown_light.png index 5bf92c1..3fdb79c 100644 Binary files a/site/assets/chat_markdown_light.png and b/site/assets/chat_markdown_light.png differ diff --git a/site/assets/chat_provider_dark.png b/site/assets/chat_provider_dark.png index 1cb605a..21bdf62 100644 Binary files a/site/assets/chat_provider_dark.png and b/site/assets/chat_provider_dark.png differ diff --git a/site/assets/chat_provider_dark.webp b/site/assets/chat_provider_dark.webp index f5472da..52b3174 100644 Binary files a/site/assets/chat_provider_dark.webp and b/site/assets/chat_provider_dark.webp differ diff --git a/site/assets/login_dark.png b/site/assets/login_dark.png new file mode 100644 index 0000000..0839452 Binary files /dev/null and b/site/assets/login_dark.png differ diff --git a/site/assets/login_dark.webp b/site/assets/login_dark.webp new file mode 100644 index 0000000..776b890 Binary files /dev/null and b/site/assets/login_dark.webp differ diff --git a/site/assets/login_light.png b/site/assets/login_light.png new file mode 100644 index 0000000..18ab6cc Binary files /dev/null and b/site/assets/login_light.png differ diff --git a/site/assets/login_light.webp b/site/assets/login_light.webp new file mode 100644 index 0000000..54b4253 Binary files /dev/null and b/site/assets/login_light.webp differ diff --git a/site/assets/providers_dark.png b/site/assets/providers_dark.png index 289189f..2068c73 100644 Binary files a/site/assets/providers_dark.png and b/site/assets/providers_dark.png differ diff --git a/site/assets/providers_dark.webp b/site/assets/providers_dark.webp index f987e61..f85b677 100644 Binary files a/site/assets/providers_dark.webp and b/site/assets/providers_dark.webp differ diff --git a/site/assets/providers_light.png b/site/assets/providers_light.png index 24b4e58..3f751ef 100644 Binary files a/site/assets/providers_light.png and b/site/assets/providers_light.png differ diff --git a/site/assets/providers_light.webp b/site/assets/providers_light.webp index 9df04e3..15b3a34 100644 Binary files a/site/assets/providers_light.webp and b/site/assets/providers_light.webp differ diff --git a/site/assets/sessions_search_dark.png b/site/assets/sessions_search_dark.png index 6e353c4..634fc9b 100644 Binary files a/site/assets/sessions_search_dark.png and b/site/assets/sessions_search_dark.png differ diff --git a/site/assets/settings_dark.png b/site/assets/settings_dark.png index 17be56e..b2cc692 100644 Binary files a/site/assets/settings_dark.png and b/site/assets/settings_dark.png differ diff --git a/site/assets/settings_dark.webp b/site/assets/settings_dark.webp index fa732da..0d60af9 100644 Binary files a/site/assets/settings_dark.webp and b/site/assets/settings_dark.webp differ diff --git a/site/assets/settings_light.png b/site/assets/settings_light.png index 1e0d74b..890ffa6 100644 Binary files a/site/assets/settings_light.png and b/site/assets/settings_light.png differ diff --git a/site/assets/settings_light.webp b/site/assets/settings_light.webp index 00e750e..6335698 100644 Binary files a/site/assets/settings_light.webp and b/site/assets/settings_light.webp differ diff --git a/site/index.html b/site/index.html index c74bf87..d3677b0 100644 --- a/site/index.html +++ b/site/index.html @@ -32,7 +32,7 @@

Selbst gehosteter, schlanker KI-Assistent – Go-Backend + Android-App.

Ein einzelnes Go-Binary mit SQLite spricht mit jedem OpenAI-kompatiblen Anbieter, nutzt Tools und streamt Antworten live in die native Android-App. Deine Schlüssel bleiben auf deinem Server.

- Android-App laden · v0.2.0 (1,9 MB) + Android-App laden · v0.3.0 (1,9 MB) Quickstart ansehen
@@ -51,7 +51,8 @@

Abbrechen

Stopp-Button in der App: Der Abbruch reicht bis zum Anbieter, die Teilantwort bleibt gespeichert.

Session-Zusammenfassung

Lange Chats werden automatisch per LLM zusammengefasst – die Originalnachrichten bleiben erhalten.

Anbieter pro Session

Global umschalten oder für einzelne Chats ein anderes Modell wählen – direkt aus der App.

-

Sichere Defaults

Token-Pflicht, nur localhost, Origin-Prüfung, TLS immer an, Keys nie in der App, Release nur HTTPS.

+

Konten & Login

Registrierung und Anmeldung in der App – jeder Nutzer hat eigene, getrennte Chats. Passwörter als bcrypt-Hash, Tokens widerrufbar.

+

Sichere Defaults

Login-Pflicht, Rate-Limit, nur localhost, Origin-Prüfung, TLS immer an, Keys nie in der App, Release nur HTTPS.

ARM & Termux

Statische Builds für Linux amd64, arm64 und armv7 sowie Termux auf Android.

@@ -64,7 +65,7 @@
Chat mit Anbieter-Auswahl pro Session im Header, dunkles Design
Anbieter pro Session
Einstellungen mit Anbieter- und Modellauswahl, dunkles Design
Anbieter & Modell
Sessions durchsuchen, dunkles Design
Sessions suchen & umbenennen
-
Einstellungen mit Server-URL und Token, helles Design
Verbindung
+
Anmelde- und Registrierungsbildschirm, helles Design
Anmelden & Registrieren
Chat, dunkles Design
Dunkles Design
@@ -88,7 +89,7 @@ curl -H "Authorization: Bearer $BYTECLAW_AUTH_TOKEN" \