app 0.3.0: Login/Registrierung, Abmelden, 401 → Login; Previews + Landingpage v0.3.0

This commit is contained in:
OpenClaw Dev 2026-10-31 11:56:53 +01:00
parent 0ef39cffd6
commit 97646da970
48 changed files with 293 additions and 47 deletions

View file

@ -20,7 +20,7 @@ Abhängigkeiten des Backends: `gopkg.in/yaml.v3`, `modernc.org/sqlite`, `github.
## Sichere Defaults ## 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`. - 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. - 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. - TLS-Zertifikatsprüfung ist überall an (LLM-Provider, `web_fetch`); es gibt keinen Schalter zum Abschalten.

View file

@ -31,8 +31,8 @@ android {
applicationId = "dev.byteclaw.client" applicationId = "dev.byteclaw.client"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 2 versionCode = 3
versionName = "0.2.0" versionName = "0.3.0"
} }
signingConfigs { signingConfigs {

View file

@ -26,6 +26,7 @@ import dev.byteclaw.client.ui.ChatScreen
import dev.byteclaw.client.ui.ChatViewModel import dev.byteclaw.client.ui.ChatViewModel
import dev.byteclaw.client.ui.SessionsScreen import dev.byteclaw.client.ui.SessionsScreen
import dev.byteclaw.client.ui.SettingsScreen import dev.byteclaw.client.ui.SettingsScreen
import dev.byteclaw.client.ui.LoginScreen
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var voiceCallback: ((String) -> Unit)? = null private var voiceCallback: ((String) -> Unit)? = null
@ -61,7 +62,16 @@ class MainActivity : ComponentActivity() {
val vm: ChatViewModel = viewModel() val vm: ChatViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle() val state by vm.state.collectAsStateWithLifecycle()
val nav = rememberNavController() 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") { composable("chat") {
androidx.compose.runtime.LaunchedEffect(state.settings) { if (state.settings.isConfigured) vm.loadProviders() } androidx.compose.runtime.LaunchedEffect(state.settings) { if (state.settings.isConfigured) vm.loadProviders() }
ChatScreen( ChatScreen(
@ -83,7 +93,7 @@ class MainActivity : ComponentActivity() {
SettingsScreen(state, onSave = vm::saveSettings, onTest = { s, cb -> vm.testConnection(s, cb) }, onBack = { SettingsScreen(state, onSave = vm::saveSettings, onTest = { s, cb -> vm.testConnection(s, cb) }, onBack = {
if (!nav.popBackStack()) nav.navigate("chat") { popUpTo("settings") { inclusive = true } } if (!nav.popBackStack()) nav.navigate("chat") { popUpTo("settings") { inclusive = true } }
}, onLoadProviders = { vm.loadProviders() }, onLoadModels = { vm.loadModels(it) }, }, onLoadProviders = { vm.loadProviders() }, onLoadModels = { vm.loadModels(it) },
onSelectProvider = { p, m -> vm.selectProvider(p, m) }) onSelectProvider = { p, m -> vm.selectProvider(p, m) }, onLogout = vm::logout)
} }
} }
} }

View file

@ -17,7 +17,10 @@ import okhttp3.WebSocketListener
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit 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. */ /** REST + WebSocket client for the ByteClaw Go backend. TLS verification uses system defaults. */
class ByteClawApi(private val settings: ServerSettings) { 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) { private suspend fun call(req: Request): String = withContext(Dispatchers.IO) {
http.newCall(req).execute().use { r: Response -> http.newCall(req).execute().use { r: Response ->
val body = r.body?.string().orEmpty() 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 body
} }
} }
@ -42,6 +46,13 @@ class ByteClawApi(private val settings: ServerSettings) {
http.newCall(Request.Builder().url(url("/healthz")).build()).execute().use { it.isSuccessful } 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 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())) 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 { 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 JSON = "application/json".toMediaType()
private val shared = OkHttpClient.Builder() private val shared = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS) .connectTimeout(15, TimeUnit.SECONDS)

View file

@ -92,3 +92,33 @@ object ProviderPresets {
@Serializable @Serializable
data class RenameRequest(val title: String) 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
}
}

View file

@ -5,7 +5,7 @@ import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey 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() val isConfigured get() = serverUrl.isNotBlank() && token.isNotBlank()
} }
@ -22,14 +22,17 @@ class SettingsStore(context: Context) {
fun load() = ServerSettings( fun load() = ServerSettings(
serverUrl = prefs.getString(KEY_URL, "") ?: "", serverUrl = prefs.getString(KEY_URL, "") ?: "",
token = prefs.getString(KEY_TOKEN, "") ?: "", token = prefs.getString(KEY_TOKEN, "") ?: "",
username = prefs.getString(KEY_USER, "") ?: "",
) )
fun save(s: ServerSettings) { 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 { private companion object {
const val KEY_URL = "server_url" const val KEY_URL = "server_url"
const val KEY_TOKEN = "token" const val KEY_TOKEN = "token"
const val KEY_USER = "username"
} }
} }

View file

@ -5,6 +5,8 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.byteclaw.client.data.ActiveProvider import dev.byteclaw.client.data.ActiveProvider
import dev.byteclaw.client.data.ByteClawApi 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.ProviderInfo
import dev.byteclaw.client.data.ProviderPresets import dev.byteclaw.client.data.ProviderPresets
import dev.byteclaw.client.data.ServerSettings import dev.byteclaw.client.data.ServerSettings
@ -39,6 +41,9 @@ data class UiState(
val sessionProvider: ActiveProvider? = null, val sessionProvider: ActiveProvider? = null,
/** null = unknown, true/false = last health check result. */ /** null = unknown, true/false = last health check result. */
val online: Boolean? = null, 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. */ /** What the current chat effectively uses: session override, else the global active provider. */
val effectiveProvider: ActiveProvider? get() = sessionProvider ?: provider.active 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) } _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 { fun testConnection(s: ServerSettings, onResult: (String) -> Unit) = viewModelScope.launch {
val msg = runCatching { val msg = runCatching {
val i = ByteClawApi(s).info() val i = ByteClawApi(s).info()
@ -91,7 +144,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
fun refreshSessions() = viewModelScope.launch { fun refreshSessions() = viewModelScope.launch {
runCatching { api().sessions() } runCatching { api().sessions() }
.onSuccess { list -> _state.update { it.copy(sessions = list) } } .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 { 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) }) } _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 { fun deleteSession(id: String) = viewModelScope.launch {
@ -122,7 +175,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
if (_state.value.sessionId == id) newChat() if (_state.value.sessionId == id) newChat()
refreshSessions() refreshSessions()
} }
.onFailure { e -> _state.update { it.copy(error = e.message) } } .onFailure { e -> fail(e) }
} }
fun send(text: String) { fun send(text: String) {
@ -148,7 +201,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
System.currentTimeMillis() - started) System.currentTimeMillis() - started)
} }
"cancelled" -> markCancelled() "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() }) 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 if (t.isEmpty()) return@launch
runCatching { api().renameSession(id, t) } 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 }) } } .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. */ /** 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 { viewModelScope.launch {
runCatching { api().setSessionProvider(sid, name, model) } runCatching { api().setSessionProvider(sid, name, model) }
.onSuccess { a -> _state.update { it.copy(sessionProvider = a.takeIf { x -> x.provider.isNotBlank() }) } } .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 { fun loadProviders() = viewModelScope.launch {
runCatching { api().providers() } runCatching { api().providers() }
.onSuccess { r -> _state.update { it.copy(provider = it.provider.copy(providers = r.providers, active = r.active, status = null)) } } .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 { fun loadModels(name: String) = viewModelScope.launch {

View file

@ -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)
}
}
}

View file

@ -270,11 +270,9 @@ fun SettingsScreen(
onLoadModels: (String) -> Unit = {}, onLoadModels: (String) -> Unit = {},
onSelectProvider: (String, String) -> Unit = { _, _ -> }, onSelectProvider: (String, String) -> Unit = { _, _ -> },
initialStatus: String? = null, 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) } var status by remember { mutableStateOf(initialStatus) }
val urlError = if (url.isBlank()) null else ByteClawApi.validateUrl(url)
LaunchedEffect(state.settings) { if (state.settings.isConfigured) onLoadProviders() } LaunchedEffect(state.settings) { if (state.settings.isConfigured) onLoadProviders() }
Scaffold(topBar = { Scaffold(topBar = {
TopAppBar(title = { Text("Einstellungen") }, TopAppBar(title = { Text("Einstellungen") },
@ -282,28 +280,18 @@ fun SettingsScreen(
}) { pad -> }) { pad ->
LazyColumn(Modifier.padding(pad).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { LazyColumn(Modifier.padding(pad).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
item { item {
OutlinedTextField(url, { url = it }, Modifier.fillMaxWidth(), label = { Text("Server-URL") }, Card(Modifier.fillMaxWidth()) {
placeholder = { Text("https://claw.example.org") }, singleLine = true, Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
isError = urlError != null, supportingText = { urlError?.let { Text(it) } }, Text("Konto", style = MaterialTheme.typography.titleMedium)
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)) Text("Angemeldet als ${state.settings.username.ifBlank { "unbekannt" }}")
} Text("Server: ${state.settings.serverUrl}", style = MaterialTheme.typography.bodySmall)
item { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(token, { token = it }, Modifier.fillMaxWidth(), label = { Text("Token") }, singleLine = true, OutlinedButton(onClick = {
visualTransformation = PasswordVisualTransformation(), status = "Teste…"; onTest(state.settings) { status = it }
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)) }) { Text("Verbindung testen") }
} Button(onClick = onLogout, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text("Abmelden") }
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") }
} }
} }
status?.let { item { Text(it) } } status?.let { item { Text(it) } }

View 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)
}
}

View file

@ -10,6 +10,7 @@ import dev.byteclaw.client.ui.ChatLine
import dev.byteclaw.client.ui.ChatScreen import dev.byteclaw.client.ui.ChatScreen
import dev.byteclaw.client.ui.SessionsScreen import dev.byteclaw.client.ui.SessionsScreen
import dev.byteclaw.client.ui.SettingsScreen import dev.byteclaw.client.ui.SettingsScreen
import dev.byteclaw.client.ui.LoginScreen
import dev.byteclaw.client.ui.UiState import dev.byteclaw.client.ui.UiState
import dev.byteclaw.client.ui.ProviderUi import dev.byteclaw.client.ui.ProviderUi
import dev.byteclaw.client.data.ProviderInfo import dev.byteclaw.client.data.ProviderInfo
@ -35,7 +36,7 @@ class PreviewScreenshots(private val dark: Boolean) {
maxPercentDifference = 0.1, 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) = private fun snap(name: String, content: @Composable () -> Unit) =
paparazzi.snapshot(name + if (dark) "_dark" else "_light") { ByteClawTheme(dark = dark, content = content) } 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 = {}, 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",
)
}
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Before After
Before After

BIN
preview/login_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
preview/login_light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Before After
Before After

View file

@ -1,3 +1,3 @@
/downloads/*.apk /downloads/*.apk
Content-Type: application/vnd.android.package-archive 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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

BIN
site/assets/login_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
site/assets/login_dark.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
site/assets/login_light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

View file

@ -32,7 +32,7 @@
<p class="claim">Selbst gehosteter, schlanker KI-Assistent – <span>Go-Backend</span> + <span>Android-App</span>.</p> <p class="claim">Selbst gehosteter, schlanker KI-Assistent – <span>Go-Backend</span> + <span>Android-App</span>.</p>
<p class="lead">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.</p> <p class="lead">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.</p>
<div class="cta"> <div class="cta">
<a class="btn btn-primary" href="https://git.85.190.126.179.sslip.io/crypto/byteclaw/releases/download/v0.2.0/byteclaw-0.2.0-release.apk">Android-App laden · v0.2.0 (1,9 MB)</a> <a class="btn btn-primary" href="https://git.85.190.126.179.sslip.io/crypto/byteclaw/releases/download/v0.3.0/byteclaw-0.3.0-release.apk">Android-App laden · v0.3.0 (1,9 MB)</a>
<a class="btn btn-ghost" href="#quickstart">Quickstart ansehen</a> <a class="btn btn-ghost" href="#quickstart">Quickstart ansehen</a>
</div> </div>
</div> </div>
@ -51,7 +51,8 @@
<article class="card"><h3>Abbrechen</h3><p>Stopp-Button in der App: Der Abbruch reicht bis zum Anbieter, die Teilantwort bleibt gespeichert.</p></article> <article class="card"><h3>Abbrechen</h3><p>Stopp-Button in der App: Der Abbruch reicht bis zum Anbieter, die Teilantwort bleibt gespeichert.</p></article>
<article class="card"><h3>Session-Zusammenfassung</h3><p>Lange Chats werden automatisch per LLM zusammengefasst – die Originalnachrichten bleiben erhalten.</p></article> <article class="card"><h3>Session-Zusammenfassung</h3><p>Lange Chats werden automatisch per LLM zusammengefasst – die Originalnachrichten bleiben erhalten.</p></article>
<article class="card"><h3>Anbieter pro Session</h3><p>Global umschalten oder für einzelne Chats ein anderes Modell wählen – direkt aus der App.</p></article> <article class="card"><h3>Anbieter pro Session</h3><p>Global umschalten oder für einzelne Chats ein anderes Modell wählen – direkt aus der App.</p></article>
<article class="card"><h3>Sichere Defaults</h3><p>Token-Pflicht, nur localhost, Origin-Prüfung, TLS immer an, Keys nie in der App, Release nur HTTPS.</p></article> <article class="card"><h3>Konten &amp; Login</h3><p>Registrierung und Anmeldung in der App – jeder Nutzer hat eigene, getrennte Chats. Passwörter als bcrypt-Hash, Tokens widerrufbar.</p></article>
<article class="card"><h3>Sichere Defaults</h3><p>Login-Pflicht, Rate-Limit, nur localhost, Origin-Prüfung, TLS immer an, Keys nie in der App, Release nur HTTPS.</p></article>
<article class="card"><h3>ARM &amp; Termux</h3><p>Statische Builds für Linux amd64, arm64 und armv7 sowie Termux auf Android.</p></article> <article class="card"><h3>ARM &amp; Termux</h3><p>Statische Builds für Linux amd64, arm64 und armv7 sowie Termux auf Android.</p></article>
</div> </div>
</section> </section>
@ -64,7 +65,7 @@
<figure><div class="phone"><picture><source srcset="assets/chat_provider_dark.webp" type="image/webp"><img loading="lazy" src="assets/chat_provider_dark.png" alt="Chat mit Anbieter-Auswahl pro Session im Header, dunkles Design" width="450" height="1000"></picture></div><figcaption>Anbieter pro Session</figcaption></figure> <figure><div class="phone"><picture><source srcset="assets/chat_provider_dark.webp" type="image/webp"><img loading="lazy" src="assets/chat_provider_dark.png" alt="Chat mit Anbieter-Auswahl pro Session im Header, dunkles Design" width="450" height="1000"></picture></div><figcaption>Anbieter pro Session</figcaption></figure>
<figure><div class="phone"><picture><source srcset="assets/providers_dark.webp" type="image/webp"><img loading="lazy" src="assets/providers_dark.png" alt="Einstellungen mit Anbieter- und Modellauswahl, dunkles Design" width="450" height="1000"></picture></div><figcaption>Anbieter &amp; Modell</figcaption></figure> <figure><div class="phone"><picture><source srcset="assets/providers_dark.webp" type="image/webp"><img loading="lazy" src="assets/providers_dark.png" alt="Einstellungen mit Anbieter- und Modellauswahl, dunkles Design" width="450" height="1000"></picture></div><figcaption>Anbieter &amp; Modell</figcaption></figure>
<figure><div class="phone"><picture><source srcset="assets/sessions_search_dark.webp" type="image/webp"><img loading="lazy" src="assets/sessions_search_dark.png" alt="Sessions durchsuchen, dunkles Design" width="450" height="1000"></picture></div><figcaption>Sessions suchen &amp; umbenennen</figcaption></figure> <figure><div class="phone"><picture><source srcset="assets/sessions_search_dark.webp" type="image/webp"><img loading="lazy" src="assets/sessions_search_dark.png" alt="Sessions durchsuchen, dunkles Design" width="450" height="1000"></picture></div><figcaption>Sessions suchen &amp; umbenennen</figcaption></figure>
<figure><div class="phone"><picture><source srcset="assets/settings_light.webp" type="image/webp"><img loading="lazy" src="assets/settings_light.png" alt="Einstellungen mit Server-URL und Token, helles Design" width="450" height="1000"></picture></div><figcaption>Verbindung</figcaption></figure> <figure><div class="phone"><picture><source srcset="assets/login_light.webp" type="image/webp"><img loading="lazy" src="assets/login_light.png" alt="Anmelde- und Registrierungsbildschirm, helles Design" width="450" height="1000"></picture></div><figcaption>Anmelden &amp; Registrieren</figcaption></figure>
<figure><div class="phone"><picture><source srcset="assets/chat_dark.webp" type="image/webp"><img loading="lazy" src="assets/chat_dark.png" alt="Chat, dunkles Design" width="450" height="1000"></picture></div><figcaption>Dunkles Design</figcaption></figure> <figure><div class="phone"><picture><source srcset="assets/chat_dark.webp" type="image/webp"><img loading="lazy" src="assets/chat_dark.png" alt="Chat, dunkles Design" width="450" height="1000"></picture></div><figcaption>Dunkles Design</figcaption></figure>
</div> </div>
</section> </section>
@ -88,7 +89,7 @@ curl -H "Authorization: Bearer $BYTECLAW_AUTH_TOKEN" \
<footer class="footer"> <footer class="footer">
<p><img src="assets/icon.svg" alt="" width="20" height="20"> ByteClaw · selbst gehostet, Go + Android</p> <p><img src="assets/icon.svg" alt="" width="20" height="20"> ByteClaw · selbst gehostet, Go + Android</p>
<p class="muted">Screenshots zeigen Demo-Daten. APK v0.2.0 · Android 8+ · signiert.</p> <p class="muted">Screenshots zeigen Demo-Daten. APK v0.3.0 · Android 8+ · signiert.</p>
</footer> </footer>
<script> <script>
document.querySelectorAll('[data-copy]').forEach(b=>b.addEventListener('click',()=>{ document.querySelectorAll('[data-copy]').forEach(b=>b.addEventListener('click',()=>{