Einladungscodes: registration_mode open|invite|closed, Admin-API /api/invites, App mit Code-Feld und Code-Verwaltung; v0.3.2
|
|
@ -31,8 +31,8 @@ android {
|
|||
applicationId = "dev.byteclaw.client"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 4
|
||||
versionName = "0.3.1"
|
||||
versionCode = 5
|
||||
versionName = "0.3.2"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class MainActivity : ComponentActivity() {
|
|||
}
|
||||
NavHost(nav, startDestination = if (loggedIn) "chat" else "login") {
|
||||
composable("login") {
|
||||
LoginScreen(state, onAuthenticate = { u, n, p, r -> vm.authenticate(u, n, p, r) },
|
||||
LoginScreen(state, onAuthenticate = { u, n, p, r, i -> vm.authenticate(u, n, p, r, i) }, onCheckMode = vm::checkRegistrationMode,
|
||||
onAdminToken = { u, t -> vm.useAdminToken(u, t) })
|
||||
}
|
||||
composable("chat") {
|
||||
|
|
@ -93,7 +93,8 @@ 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) }, onLogout = vm::logout)
|
||||
onSelectProvider = { p, m -> vm.selectProvider(p, m) }, onLogout = vm::logout,
|
||||
onLoadInvites = { vm.loadInvites() }, onCreateInvite = { n, h -> vm.createInvite(n, h) }, onRevokeInvite = { vm.revokeInvite(it) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,18 @@ class ByteClawApi(private val settings: ServerSettings) {
|
|||
suspend fun messages(sessionId: String): List<Message> =
|
||||
json.decodeFromString(call(Request.Builder().url(url("/api/sessions/$sessionId/messages")).build()))
|
||||
|
||||
suspend fun invites(): List<Invite> =
|
||||
json.decodeFromString(call(Request.Builder().url(url("/api/invites")).build()))
|
||||
|
||||
suspend fun createInvite(maxUses: Int, expiresInHours: Int): Invite {
|
||||
val body = json.encodeToString(InviteRequest.serializer(), InviteRequest(maxUses, expiresInHours)).toRequestBody(JSON)
|
||||
return json.decodeFromString(call(Request.Builder().url(url("/api/invites")).post(body).build()))
|
||||
}
|
||||
|
||||
suspend fun deleteInvite(code: String) {
|
||||
call(Request.Builder().url(url("/api/invites/$code")).delete().build())
|
||||
}
|
||||
|
||||
suspend fun deleteSession(sessionId: String) {
|
||||
call(Request.Builder().url(url("/api/sessions/$sessionId")).delete().build())
|
||||
}
|
||||
|
|
@ -132,10 +144,11 @@ class ByteClawApi(private val settings: ServerSettings) {
|
|||
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 =
|
||||
suspend fun authenticate(serverUrl: String, username: String, password: String, register: Boolean, inviteCode: String = ""): 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 creds = Credentials(username.trim(), password, inviteCode.trim().uppercase().takeIf { register && it.isNotBlank() })
|
||||
val body = authJson.encodeToString(Credentials.serializer(), creds).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()
|
||||
|
|
@ -144,6 +157,18 @@ class ByteClawApi(private val settings: ServerSettings) {
|
|||
}
|
||||
}
|
||||
|
||||
/** Registration mode of a server; older servers without the endpoint count as "open". */
|
||||
suspend fun registrationMode(serverUrl: String): String = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val req = Request.Builder().url((serverUrl.trim().trimEnd('/') + "/api/auth/config").toHttpUrl()).build()
|
||||
shared.newCall(req).execute().use { r ->
|
||||
if (r.code == 404) "open"
|
||||
else if (!r.isSuccessful) null
|
||||
else authJson.decodeFromString(AuthConfig.serializer(), r.body?.string().orEmpty()).registrationMode
|
||||
}
|
||||
}.getOrNull() ?: "unknown"
|
||||
}
|
||||
|
||||
private val JSON = "application/json".toMediaType()
|
||||
private val shared = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
|
|
|
|||
|
|
@ -94,7 +94,29 @@ object ProviderPresets {
|
|||
data class RenameRequest(val title: String)
|
||||
|
||||
@Serializable
|
||||
data class Credentials(val username: String, val password: String)
|
||||
data class Credentials(
|
||||
val username: String,
|
||||
val password: String,
|
||||
@SerialName("invite_code") val inviteCode: String? = null,
|
||||
)
|
||||
|
||||
/** GET /api/auth/config (public): "open", "invite" or "closed". */
|
||||
@Serializable
|
||||
data class AuthConfig(@SerialName("registration_mode") val registrationMode: String = "open")
|
||||
|
||||
/** Invite code (admin only). */
|
||||
@Serializable
|
||||
data class Invite(
|
||||
val code: String = "",
|
||||
@SerialName("max_uses") val maxUses: Int = 1,
|
||||
val uses: Int = 0,
|
||||
@SerialName("expires_at") val expiresAt: String? = null,
|
||||
@SerialName("created_at") val createdAt: String = "",
|
||||
@SerialName("used_by") val usedBy: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class InviteRequest(@SerialName("max_uses") val maxUses: Int = 1, @SerialName("expires_in_hours") val expiresInHours: Int = 0)
|
||||
|
||||
@Serializable
|
||||
data class AuthUser(val id: Long = 0, val username: String = "", val admin: Boolean = false)
|
||||
|
|
@ -107,6 +129,10 @@ object AuthErrors {
|
|||
fun german(code: Int, body: String): String = when {
|
||||
code == 401 -> "Benutzername oder Passwort falsch"
|
||||
code == 409 -> "Benutzername ist bereits vergeben"
|
||||
code == 400 && body.contains("invite code required") -> "Einladungscode erforderlich"
|
||||
code == 403 && body.contains("invalid invite") -> "Einladungscode ist ungültig"
|
||||
code == 403 && body.contains("already used") -> "Einladungscode wurde bereits verwendet"
|
||||
code == 403 && body.contains("expired") -> "Einladungscode ist abgelaufen"
|
||||
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, . _ -)"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import android.app.Application
|
|||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dev.byteclaw.client.data.ActiveProvider
|
||||
import dev.byteclaw.client.data.Invite
|
||||
import dev.byteclaw.client.data.ByteClawApi
|
||||
import dev.byteclaw.client.data.AuthErrors
|
||||
import dev.byteclaw.client.data.UnauthorizedException
|
||||
|
|
@ -46,6 +47,11 @@ data class UiState(
|
|||
val authError: String? = null,
|
||||
/** From /api/auth/me: only the admin may change providers. */
|
||||
val isAdmin: Boolean = false,
|
||||
/** Registration mode of the server entered on the login screen: open, invite, closed or unknown. */
|
||||
val registrationMode: String = "unknown",
|
||||
/** Admin: invite codes and status of the last invite action. */
|
||||
val invites: List<Invite> = emptyList(),
|
||||
val inviteStatus: String? = null,
|
||||
) {
|
||||
/** What the current chat effectively uses: session override, else the global active provider. */
|
||||
val effectiveProvider: ActiveProvider? get() = sessionProvider ?: provider.active
|
||||
|
|
@ -81,13 +87,40 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
|
|||
}
|
||||
|
||||
/** Login or registration; on success the token is stored encrypted and the chat opens. */
|
||||
fun authenticate(serverUrl: String, username: String, password: String, register: Boolean) {
|
||||
fun checkRegistrationMode(serverUrl: String) {
|
||||
if (ByteClawApi.validateUrl(serverUrl) != null) return
|
||||
viewModelScope.launch {
|
||||
val m = ByteClawApi.registrationMode(serverUrl)
|
||||
_state.update { it.copy(registrationMode = m) }
|
||||
}
|
||||
}
|
||||
|
||||
fun loadInvites() = viewModelScope.launch {
|
||||
runCatching { api().invites() }
|
||||
.onSuccess { l -> _state.update { it.copy(invites = l) } }
|
||||
.onFailure { e -> _state.update { it.copy(inviteStatus = "Codes laden fehlgeschlagen: ${e.message}") } }
|
||||
}
|
||||
|
||||
fun createInvite(maxUses: Int, expiresInHours: Int) = viewModelScope.launch {
|
||||
runCatching { api().createInvite(maxUses, expiresInHours) }
|
||||
.onSuccess { i -> _state.update { it.copy(inviteStatus = "Neuer Code: ${i.code}") }; loadInvites() }
|
||||
.onFailure { e -> _state.update { it.copy(inviteStatus = "Fehler: ${e.message}") } }
|
||||
}
|
||||
|
||||
fun revokeInvite(code: String) = viewModelScope.launch {
|
||||
runCatching { api().deleteInvite(code) }
|
||||
.onSuccess { _state.update { it.copy(inviteStatus = "Code $code widerrufen") }; loadInvites() }
|
||||
.onFailure { e -> _state.update { it.copy(inviteStatus = "Fehler: ${e.message}") } }
|
||||
}
|
||||
|
||||
fun authenticate(serverUrl: String, username: String, password: String, register: Boolean, inviteCode: String = "") {
|
||||
val urlErr = ByteClawApi.validateUrl(serverUrl)
|
||||
val err = urlErr ?: AuthErrors.validate(username, password)
|
||||
?: if (register && _state.value.registrationMode == "invite" && inviteCode.isBlank()) "Einladungscode erforderlich" else null
|
||||
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) }
|
||||
runCatching { ByteClawApi.authenticate(serverUrl, username, password, register, inviteCode) }
|
||||
.onSuccess { r ->
|
||||
store.save(ServerSettings(serverUrl, r.token, r.user.username))
|
||||
_state.update { UiState(settings = store.load()) }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
package dev.byteclaw.client.ui
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.byteclaw.client.data.Invite
|
||||
|
||||
private val expiryOptions = listOf(0 to "Nie", 24 to "1 Tag", 24 * 7 to "7 Tage", 24 * 30 to "30 Tage")
|
||||
|
||||
/** Admin only: create, copy/share, list and revoke invite codes. */
|
||||
@Composable
|
||||
fun InviteSection(
|
||||
state: UiState,
|
||||
onLoad: () -> Unit,
|
||||
onCreate: (maxUses: Int, expiresInHours: Int) -> Unit,
|
||||
onRevoke: (String) -> Unit,
|
||||
) {
|
||||
LaunchedEffect(Unit) { onLoad() }
|
||||
var maxUses by remember { mutableStateOf("1") }
|
||||
var expiry by remember { mutableIntStateOf(0) }
|
||||
var confirm by remember { mutableStateOf<String?>(null) }
|
||||
val clip = LocalClipboardManager.current
|
||||
val ctx = LocalContext.current
|
||||
fun share(code: String) {
|
||||
val text = "Einladung zu ByteClaw: ${state.settings.serverUrl}\nEinladungscode: $code"
|
||||
val send = Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, text)
|
||||
runCatching { ctx.startActivity(Intent.createChooser(send, null)) }
|
||||
}
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Einladungscodes", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = onLoad) { Text("Aktualisieren") }
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(maxUses, { v -> maxUses = v.filter { it.isDigit() }.take(4) }, Modifier.width(110.dp),
|
||||
label = { Text("Nutzungen") }, singleLine = true)
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
OutlinedButton(onClick = { open = true }) { Text("Gültig: " + expiryOptions.first { it.first == expiry }.second) }
|
||||
DropdownMenu(open, { open = false }) {
|
||||
expiryOptions.forEach { (h, l) -> DropdownMenuItem(text = { Text(l) }, onClick = { expiry = h; open = false }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Button(onClick = { onCreate((maxUses.toIntOrNull() ?: 1).coerceIn(1, 1000), expiry) }) { Text("Code erstellen") }
|
||||
state.inviteStatus?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||
if (state.invites.isEmpty()) Text("Noch keine Codes.", style = MaterialTheme.typography.bodySmall)
|
||||
state.invites.forEach { inv -> InviteRow(inv, onCopy = { clip.setText(AnnotatedString(inv.code)) }, onShare = { share(inv.code) }, onRevoke = { confirm = inv.code }) }
|
||||
}
|
||||
}
|
||||
confirm?.let { code ->
|
||||
AlertDialog(onDismissRequest = { confirm = null }, title = { Text("Code widerrufen?") },
|
||||
text = { Text("$code kann danach nicht mehr verwendet werden. Bereits registrierte Konten bleiben bestehen.") },
|
||||
confirmButton = { TextButton(onClick = { onRevoke(code); confirm = null }) { Text("Widerrufen") } },
|
||||
dismissButton = { TextButton(onClick = { confirm = null }) { Text("Abbrechen") } })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InviteRow(inv: Invite, onCopy: () -> Unit, onShare: () -> Unit, onRevoke: () -> Unit) {
|
||||
val usedUp = inv.uses >= inv.maxUses
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(inv.code, fontFamily = FontFamily.Monospace, color = if (usedUp) MaterialTheme.colorScheme.outline else MaterialTheme.colorScheme.onSurface)
|
||||
val exp = inv.expiresAt?.take(10)?.let { " · bis $it" } ?: ""
|
||||
val by = if (inv.usedBy.isNotEmpty()) " · " + inv.usedBy.joinToString() else ""
|
||||
Text("${inv.uses}/${inv.maxUses} genutzt$exp$by", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline)
|
||||
}
|
||||
IconButton(onClick = onCopy) { Icon(Icons.Filled.ContentCopy, "Kopieren") }
|
||||
IconButton(onClick = onShare, enabled = !usedUp) { Icon(Icons.Filled.Share, "Teilen") }
|
||||
IconButton(onClick = onRevoke) { Icon(Icons.Filled.Delete, "Widerrufen") }
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,8 @@ import dev.byteclaw.client.data.ByteClawApi
|
|||
@Composable
|
||||
fun LoginScreen(
|
||||
state: UiState,
|
||||
onAuthenticate: (url: String, user: String, pass: String, register: Boolean) -> Unit,
|
||||
onAuthenticate: (url: String, user: String, pass: String, register: Boolean, invite: String) -> Unit,
|
||||
onCheckMode: (url: String) -> Unit = {},
|
||||
onAdminToken: (url: String, token: String) -> Unit = { _, _ -> },
|
||||
initialRegister: Boolean = false,
|
||||
initialUser: String? = null,
|
||||
|
|
@ -30,6 +31,9 @@ fun LoginScreen(
|
|||
var pass2 by remember { mutableStateOf("") }
|
||||
var adminMode by remember { mutableStateOf(false) }
|
||||
var token by remember { mutableStateOf("") }
|
||||
var invite by remember { mutableStateOf("") }
|
||||
LaunchedEffect(url, register) { if (register) { kotlinx.coroutines.delay(400); onCheckMode(url) } }
|
||||
val needInvite = register && state.registrationMode == "invite"
|
||||
val urlError = if (url.isBlank()) null else ByteClawApi.validateUrl(url)
|
||||
val mismatch = register && pass2.isNotEmpty() && pass != pass2
|
||||
|
||||
|
|
@ -65,14 +69,22 @@ fun LoginScreen(
|
|||
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))
|
||||
if (state.registrationMode == "closed") {
|
||||
Text("Registrierung ist auf diesem Server deaktiviert.", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
if (needInvite) {
|
||||
OutlinedTextField(invite, { invite = it.uppercase() }, Modifier.fillMaxWidth(), label = { Text("Einladungscode") },
|
||||
placeholder = { Text("BC-XXXX-XXXX") }, singleLine = true,
|
||||
supportingText = { Text("Diesen Server kann man nur mit Einladung nutzen – frag den Admin nach einem Code.") })
|
||||
}
|
||||
}
|
||||
}
|
||||
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 (adminMode) token.isNotBlank() else user.isNotBlank() && pass.isNotEmpty() && (!register || pass == pass2 && (!needInvite || invite.isNotBlank()))),
|
||||
onClick = { if (adminMode) onAdminToken(url, token) else onAuthenticate(url, user, pass, register, invite) },
|
||||
) {
|
||||
if (state.authBusy) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
else Text(when { adminMode -> "Mit Token verbinden"; register -> "Konto erstellen"; else -> "Anmelden" })
|
||||
|
|
|
|||
|
|
@ -271,6 +271,9 @@ fun SettingsScreen(
|
|||
onSelectProvider: (String, String) -> Unit = { _, _ -> },
|
||||
initialStatus: String? = null,
|
||||
onLogout: () -> Unit = {},
|
||||
onLoadInvites: () -> Unit = {},
|
||||
onCreateInvite: (Int, Int) -> Unit = { _, _ -> },
|
||||
onRevokeInvite: (String) -> Unit = {},
|
||||
) {
|
||||
var status by remember { mutableStateOf(initialStatus) }
|
||||
LaunchedEffect(state.settings) { if (state.settings.isConfigured) onLoadProviders() }
|
||||
|
|
@ -298,6 +301,7 @@ fun SettingsScreen(
|
|||
if (state.settings.isConfigured) {
|
||||
if (state.isAdmin) {
|
||||
item { ProviderSection(state.provider, onLoadProviders, onLoadModels, onSelectProvider) }
|
||||
item { InviteSection(state, onLoadInvites, onCreateInvite, onRevokeInvite) }
|
||||
} else {
|
||||
item {
|
||||
Text("LLM-Provider: " + (state.provider.active?.let { ProviderPresets.label(it.provider) + " · " + it.model } ?: "–") +
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ 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.InviteSection
|
||||
import dev.byteclaw.client.ui.UiState
|
||||
import dev.byteclaw.client.ui.ProviderUi
|
||||
import dev.byteclaw.client.data.ProviderInfo
|
||||
|
|
@ -152,13 +153,25 @@ class PreviewScreenshots(private val dark: Boolean) {
|
|||
}
|
||||
|
||||
@Test fun login() = snap("login") {
|
||||
LoginScreen(state = UiState(isAdmin = true, settings = ServerSettings("https://demo.byteclaw.example", "", "crypto")), onAuthenticate = { _, _, _, _ -> })
|
||||
LoginScreen(state = UiState(isAdmin = true, settings = ServerSettings("https://demo.byteclaw.example", "", "crypto")), onAuthenticate = { _, _, _, _, _ -> })
|
||||
}
|
||||
|
||||
@Test fun loginRegister() = snap("login_register") {
|
||||
LoginScreen(
|
||||
state = UiState(isAdmin = true, settings = ServerSettings("https://demo.byteclaw.example"), authError = "Benutzername ist bereits vergeben"),
|
||||
onAuthenticate = { _, _, _, _ -> }, initialRegister = true, initialUser = "crypto",
|
||||
state = UiState(isAdmin = true, settings = ServerSettings("https://demo.byteclaw.example"), registrationMode = "invite", authError = "Einladungscode wurde bereits verwendet"),
|
||||
onAuthenticate = { _, _, _, _, _ -> }, initialRegister = true, initialUser = "crypto",
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun invites() = snap("invites") {
|
||||
InviteSection(
|
||||
state = UiState(isAdmin = true, settings = ServerSettings("https://byteclaw.jodabreit-ai.de", "t", "admin"), inviteStatus = "Neuer Code: BC-7KQM-X4TP",
|
||||
invites = listOf(
|
||||
dev.byteclaw.client.data.Invite("BC-7KQM-X4TP", 1, 0, null, "2026-11-01T05:00:00Z"),
|
||||
dev.byteclaw.client.data.Invite("BC-H9RW-2NFD", 5, 2, "2026-11-08T05:00:00Z", "2026-11-01T04:00:00Z", listOf("anna", "ben")),
|
||||
dev.byteclaw.client.data.Invite("BC-3VXE-8MZA", 1, 1, null, "2026-10-31T20:00:00Z", listOf("crypto")),
|
||||
)),
|
||||
onLoad = {}, onCreate = { _, _ -> }, onRevoke = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 60 KiB |