Provider-Wechsel nur für Admin (403 für Nutzer); App blendet Provider-Auswahl für Nicht-Admins aus; v0.3.1
This commit is contained in:
parent
161bc02bb9
commit
aa58d9b258
12 changed files with 102 additions and 17 deletions
4
android/.kotlin/errors/errors-1793502564485.log
Normal file
4
android/.kotlin/errors/errors-1793502564485.log
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
kotlin version: 2.0.21
|
||||||
|
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
|
||||||
|
1. Kotlin compile daemon is ready
|
||||||
|
|
||||||
|
|
@ -31,8 +31,8 @@ android {
|
||||||
applicationId = "dev.byteclaw.client"
|
applicationId = "dev.byteclaw.client"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 3
|
versionCode = 4
|
||||||
versionName = "0.3.0"
|
versionName = "0.3.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ data class UiState(
|
||||||
/** Login screen state. */
|
/** Login screen state. */
|
||||||
val authBusy: Boolean = false,
|
val authBusy: Boolean = false,
|
||||||
val authError: String? = null,
|
val authError: String? = null,
|
||||||
|
/** From /api/auth/me: only the admin may change providers. */
|
||||||
|
val isAdmin: Boolean = false,
|
||||||
) {
|
) {
|
||||||
/** 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
|
||||||
|
|
@ -266,6 +268,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadProviders() = viewModelScope.launch {
|
fun loadProviders() = viewModelScope.launch {
|
||||||
|
runCatching { api().me() }.onSuccess { u -> _state.update { it.copy(isAdmin = u.admin) } }
|
||||||
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 -> if (e is UnauthorizedException) fail(e) else _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}")) } }
|
||||||
|
|
|
||||||
|
|
@ -296,7 +296,14 @@ fun SettingsScreen(
|
||||||
}
|
}
|
||||||
status?.let { item { Text(it) } }
|
status?.let { item { Text(it) } }
|
||||||
if (state.settings.isConfigured) {
|
if (state.settings.isConfigured) {
|
||||||
|
if (state.isAdmin) {
|
||||||
item { ProviderSection(state.provider, onLoadProviders, onLoadModels, onSelectProvider) }
|
item { ProviderSection(state.provider, onLoadProviders, onLoadModels, onSelectProvider) }
|
||||||
|
} else {
|
||||||
|
item {
|
||||||
|
Text("LLM-Provider: " + (state.provider.active?.let { ProviderPresets.label(it.provider) + " · " + it.model } ?: "–") +
|
||||||
|
"\nNur der Server-Admin kann den Provider ändern.", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -370,7 +377,7 @@ private fun SessionProviderChip(state: UiState, onPick: (String) -> Unit, initia
|
||||||
Box {
|
Box {
|
||||||
AssistChip(
|
AssistChip(
|
||||||
onClick = { open = true }, label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall) },
|
onClick = { open = true }, label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall) },
|
||||||
enabled = state.settings.isConfigured && !state.busy,
|
enabled = state.settings.isConfigured && !state.busy && state.isAdmin,
|
||||||
modifier = Modifier.height(28.dp).widthIn(max = 230.dp),
|
modifier = Modifier.height(28.dp).widthIn(max = 230.dp),
|
||||||
)
|
)
|
||||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ class PreviewScreenshots(private val dark: Boolean) {
|
||||||
|
|
||||||
@Test fun chat() = snap("chat") {
|
@Test fun chat() = snap("chat") {
|
||||||
ChatScreen(
|
ChatScreen(
|
||||||
state = UiState(
|
state = UiState(isAdmin = true,
|
||||||
settings = settings, sessionId = "demo", busy = true,
|
settings = settings, sessionId = "demo", busy = true,
|
||||||
lines = listOf(
|
lines = listOf(
|
||||||
ChatLine("user", "[DEMO] Wie spät ist es in Berlin, und was steht auf golang.org?"),
|
ChatLine("user", "[DEMO] Wie spät ist es in Berlin, und was steht auf golang.org?"),
|
||||||
|
|
@ -68,7 +68,7 @@ class PreviewScreenshots(private val dark: Boolean) {
|
||||||
|
|
||||||
@Test fun chatProvider() = snap("chat_provider") {
|
@Test fun chatProvider() = snap("chat_provider") {
|
||||||
ChatScreen(
|
ChatScreen(
|
||||||
state = UiState(
|
state = UiState(isAdmin = true,
|
||||||
settings = settings, sessionId = "demo", provider = demoProviders,
|
settings = settings, sessionId = "demo", provider = demoProviders,
|
||||||
sessionProvider = ActiveProvider("gonkarouter", "MiniMaxAI/MiniMax-M2.7"),
|
sessionProvider = ActiveProvider("gonkarouter", "MiniMaxAI/MiniMax-M2.7"),
|
||||||
lines = listOf(
|
lines = listOf(
|
||||||
|
|
@ -83,7 +83,7 @@ class PreviewScreenshots(private val dark: Boolean) {
|
||||||
|
|
||||||
@Test fun chatMarkdown() = snap("chat_markdown") {
|
@Test fun chatMarkdown() = snap("chat_markdown") {
|
||||||
ChatScreen(
|
ChatScreen(
|
||||||
state = UiState(
|
state = UiState(isAdmin = true,
|
||||||
settings = settings, sessionId = "demo", online = true,
|
settings = settings, sessionId = "demo", online = true,
|
||||||
lines = listOf(
|
lines = listOf(
|
||||||
ChatLine("user", "[DEMO] Wie lese ich in Go eine Datei zeilenweise?"),
|
ChatLine("user", "[DEMO] Wie lese ich in Go eine Datei zeilenweise?"),
|
||||||
|
|
@ -96,7 +96,7 @@ class PreviewScreenshots(private val dark: Boolean) {
|
||||||
|
|
||||||
@Test fun sessionsSearch() = snap("sessions_search") {
|
@Test fun sessionsSearch() = snap("sessions_search") {
|
||||||
SessionsScreen(
|
SessionsScreen(
|
||||||
state = UiState(
|
state = UiState(isAdmin = true,
|
||||||
settings = settings,
|
settings = settings,
|
||||||
sessions = listOf(
|
sessions = listOf(
|
||||||
Session("1", "[DEMO] Go-Datei zeilenweise lesen", "2026-10-25T03:32:00Z"),
|
Session("1", "[DEMO] Go-Datei zeilenweise lesen", "2026-10-25T03:32:00Z"),
|
||||||
|
|
@ -110,7 +110,7 @@ class PreviewScreenshots(private val dark: Boolean) {
|
||||||
|
|
||||||
@Test fun sessions() = snap("sessions") {
|
@Test fun sessions() = snap("sessions") {
|
||||||
SessionsScreen(
|
SessionsScreen(
|
||||||
state = UiState(
|
state = UiState(isAdmin = true,
|
||||||
settings = settings,
|
settings = settings,
|
||||||
sessions = listOf(
|
sessions = listOf(
|
||||||
Session("1", "[DEMO] Wie spät ist es in Berlin?", "2026-10-25T03:32:00Z"),
|
Session("1", "[DEMO] Wie spät ist es in Berlin?", "2026-10-25T03:32:00Z"),
|
||||||
|
|
@ -126,14 +126,14 @@ class PreviewScreenshots(private val dark: Boolean) {
|
||||||
|
|
||||||
@Test fun settings() = snap("settings") {
|
@Test fun settings() = snap("settings") {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
state = UiState(settings = settings), onSave = {}, onTest = { _, _ -> }, onBack = {},
|
state = UiState(isAdmin = true, settings = settings), onSave = {}, onTest = { _, _ -> }, onBack = {},
|
||||||
initialStatus = "Verbindung OK · Provider: Dahl (dahl.global) · Modell: MiniMaxAI/MiniMax-M2.7 [DEMO]",
|
initialStatus = "Verbindung OK · Provider: Dahl (dahl.global) · Modell: MiniMaxAI/MiniMax-M2.7 [DEMO]",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun providers() = snap("providers") {
|
@Test fun providers() = snap("providers") {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
state = UiState(
|
state = UiState(isAdmin = true,
|
||||||
settings = settings,
|
settings = settings,
|
||||||
provider = ProviderUi(
|
provider = ProviderUi(
|
||||||
active = ActiveProvider("dahl", "MiniMaxAI/MiniMax-M2.7"),
|
active = ActiveProvider("dahl", "MiniMaxAI/MiniMax-M2.7"),
|
||||||
|
|
@ -152,12 +152,12 @@ class PreviewScreenshots(private val dark: Boolean) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun login() = snap("login") {
|
@Test fun login() = snap("login") {
|
||||||
LoginScreen(state = UiState(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") {
|
@Test fun loginRegister() = snap("login_register") {
|
||||||
LoginScreen(
|
LoginScreen(
|
||||||
state = UiState(settings = ServerSettings("https://demo.byteclaw.example"), authError = "Benutzername ist bereits vergeben"),
|
state = UiState(isAdmin = true, settings = ServerSettings("https://demo.byteclaw.example"), authError = "Benutzername ist bereits vergeben"),
|
||||||
onAuthenticate = { _, _, _, _ -> }, initialRegister = true, initialUser = "crypto",
|
onAuthenticate = { _, _, _, _ -> }, initialRegister = true, initialUser = "crypto",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
43
internal/server/admin_test.go
Normal file
43
internal/server/admin_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProviderChangesAdminOnly(t *testing.T) {
|
||||||
|
ts, _ := setupFull(t, echo{})
|
||||||
|
ut := registerUser(t, ts.URL, "alice", "correct-horse-1")
|
||||||
|
uh := bearerHdr(ut)
|
||||||
|
if r := do(t, "GET", ts.URL+"/api/providers", "", uh); r.StatusCode != 200 {
|
||||||
|
t.Fatal("users may list providers", r.StatusCode)
|
||||||
|
}
|
||||||
|
if r := do(t, "PUT", ts.URL+"/api/providers/active", `{"provider":"dahl"}`, uh); r.StatusCode != 403 {
|
||||||
|
t.Fatal("user must not change global provider", r.StatusCode)
|
||||||
|
}
|
||||||
|
var me map[string]any
|
||||||
|
json.NewDecoder(do(t, "GET", ts.URL+"/api/auth/me", "", uh).Body).Decode(&me)
|
||||||
|
if me["admin"] != false {
|
||||||
|
t.Fatalf("%v", me)
|
||||||
|
}
|
||||||
|
// own session: chat ok, provider change forbidden
|
||||||
|
r := do(t, "POST", ts.URL+"/api/chat", `{"text":"hi"}`, uh)
|
||||||
|
if r.StatusCode != 200 {
|
||||||
|
t.Fatal("user chat", r.StatusCode)
|
||||||
|
}
|
||||||
|
var cr struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(r.Body).Decode(&cr)
|
||||||
|
if cr.SessionID != "" {
|
||||||
|
if r := do(t, "PUT", ts.URL+"/api/sessions/"+cr.SessionID+"/provider", `{"provider":"openai"}`, uh); r.StatusCode != 403 {
|
||||||
|
t.Fatal("session provider", r.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r := do(t, "POST", ts.URL+"/api/chat", `{"text":"hi","provider":"dahl"}`, uh); r.StatusCode != 403 {
|
||||||
|
t.Fatal("chat with provider", r.StatusCode)
|
||||||
|
}
|
||||||
|
if r := do(t, "PUT", ts.URL+"/api/providers/active", `{"provider":"dahl"}`, authH); r.StatusCode != 200 {
|
||||||
|
t.Fatal("admin allowed", r.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -48,6 +48,9 @@ func (s *Server) listProviders(w http.ResponseWriter, _ *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) setActiveProvider(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) setActiveProvider(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if !s.needProviders(w) {
|
if !s.needProviders(w) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -82,6 +85,9 @@ func (s *Server) listModels(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// PUT /api/sessions/{id}/provider: per-session override; empty provider clears it.
|
// PUT /api/sessions/{id}/provider: per-session override; empty provider clears it.
|
||||||
func (s *Server) setSessionProvider(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) setSessionProvider(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if !s.needProviders(w) {
|
if !s.needProviders(w) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -148,3 +154,15 @@ func (s *Server) getSummary(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
writeJSON(w, 200, sm)
|
writeJSON(w, 200, sm)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// errAdminOnly is returned when a non-admin tries to change provider settings.
|
||||||
|
var errAdminOnly = errors.New("admin only: provider settings can only be changed by the server admin")
|
||||||
|
|
||||||
|
// requireAdmin writes 403 unless the caller is the admin (static auth_token).
|
||||||
|
func requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
if principal(r.Context()).Admin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
writeErr(w, http.StatusForbidden, errAdminOnly.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ func setupFull(t *testing.T, p llm.Provider) (*httptest.Server, *store.Store) {
|
||||||
pr, _, err := pm.ForSession(ctx, sid)
|
pr, _, err := pm.ForSession(ctx, sid)
|
||||||
return pr, err
|
return pr, err
|
||||||
}
|
}
|
||||||
s := &Server{Store: st, Token: tok, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), RunTimeout: 5 * time.Second, Agent: ag, Providers: pm}
|
s := &Server{Store: st, Token: tok, AllowRegistration: true, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), RunTimeout: 5 * time.Second, Agent: ag, Providers: pm}
|
||||||
ts := httptest.NewServer(s.Handler())
|
ts := httptest.NewServer(s.Handler())
|
||||||
t.Cleanup(ts.Close)
|
t.Cleanup(ts.Close)
|
||||||
return ts, st
|
return ts, st
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,9 @@ type chatReq struct {
|
||||||
func (s *Server) startSession(ctx context.Context, req chatReq) (string, error) {
|
func (s *Server) startSession(ctx context.Context, req chatReq) (string, error) {
|
||||||
var a providers.Active
|
var a providers.Active
|
||||||
if req.SessionID == "" && req.Provider != "" && s.Providers != nil {
|
if req.SessionID == "" && req.Provider != "" && s.Providers != nil {
|
||||||
|
if !principal(ctx).Admin {
|
||||||
|
return "", errAdminOnly
|
||||||
|
}
|
||||||
var err error
|
var err error
|
||||||
if a, err = s.Providers.Validate(req.Provider, req.Model); err != nil {
|
if a, err = s.Providers.Validate(req.Provider, req.Model); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|
@ -288,6 +291,10 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id, err := s.startSession(r.Context(), req)
|
id, err := s.startSession(r.Context(), req)
|
||||||
|
if errors.Is(err, errAdminOnly) {
|
||||||
|
writeErr(w, http.StatusForbidden, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if req.SessionID == "" && req.Provider != "" {
|
if req.SessionID == "" && req.Provider != "" {
|
||||||
providerErr(w, err)
|
providerErr(w, err)
|
||||||
|
|
|
||||||
3
site/.netlify/state.json
Normal file
3
site/.netlify/state.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"siteId": "eda745c3-d798-4dab-907e-aac0a7d94433"
|
||||||
|
}
|
||||||
|
|
@ -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.3.0.apk"
|
Content-Disposition: attachment; filename="byteclaw-0.3.1.apk"
|
||||||
|
|
|
||||||
|
|
@ -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.3.0/byteclaw-0.3.0-release.apk">Android-App laden · v0.3.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.1/byteclaw-0.3.1-release.apk">Android-App laden · v0.3.1 (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>
|
||||||
|
|
@ -89,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.3.0 · Android 8+ · signiert.</p>
|
<p class="muted">Screenshots zeigen Demo-Daten. APK v0.3.1 · 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',()=>{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue