Changeset 0.37.5 (#217)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-21 13:41:25 +00:00
committed by xcaliber
parent 05b5affdac
commit 74f3cb84e9
27 changed files with 2211 additions and 667 deletions

View File

@@ -1,5 +1,106 @@
# Changelog # Changelog
## [0.37.5] — 2026-03-21
### Summary
Login + Settings surface rebuild (Phase 3 begins). First surfaces
converted from server-rendered Go templates + imperative DOM JS to
Preact component trees consuming the v0.37.3 SDK (`sw.api.*`,
`sw.auth.*`, `sw.theme.*`).
### New
- **Login surface** (`src/js/sw/surfaces/login/`) — 5 Preact components:
- `LoginSurface` — root, auth mode switching (builtin/oidc/mtls),
OIDC callback hash handler
- `Hero` — left panel with branding, feature pills, version display
- `LoginForm` — username/password, calls `sw.api.auth.login()`
- `RegisterForm` — registration flow with validation, pending approval
- `SSOPanel` — OIDC redirect + mTLS continue button
- **Settings surface** (`src/js/sw/surfaces/settings/`) — 12 files:
- `SettingsSurface` — left nav + section routing via `__SECTION__`
- `GeneralSection` — model select, system prompt, max tokens,
temperature slider, thinking toggle. Uses `sw.api.models.enabled()`
+ `sw.api.profile.settings()`
- `AppearanceSection` — theme toggle (light/dark/system) via
`sw.theme.set()`, UI scale + message font size sliders
- `ProfileSection` — avatar (upload/remove), display name, email,
password change. Uses `sw.api.profile.*`
- `ModelsSection` — searchable model table with visibility toggles.
Uses `sw.api.models.enabled()` + `sw.api.models.setPref()`
- `TeamsSection` — read-only team membership list via
`sw.api.teams.mine()`
- `BridgeSection` — thin Preact wrapper for deferred sections that
call old JS loaders (Tasks, Git Keys, Workflows, Data & Privacy)
- **`sw.toast()`** — imperative toast API on the SDK. Dynamic import
from primitives. `sw.emit('toast', { message, variant })` event
listener for surface-agnostic toast firing.
- **Nav gating** — Personas tab hidden when `allow_user_personas`
policy is false. BYOK section (Providers, Roles, Usage) hidden when
`allow_user_byok` is false. Fetched from `sw.api.admin.settings.public()`.
- **ToastContainer + DialogStack** mounted in settings surface root
(not via shell `<App>`, since surfaces render standalone).
- **`sw-login.css`** — login-specific styles extracted from inline
`<style>` in old Go template.
### Changed
- `server/version.go` — version `0.36.0``0.37.5`
- `VERSION``0.36.0``0.37.5`
- `server/pages/templates/login.html` — stripped all inline CSS/JS,
minimal shell with `<div id="login-mount">`, vendor + SDK + surface
boot script chain. Injects `__AUTH_MODE__`, `__REGISTRATION_OPEN__`,
`__BANNER__`, `__VERSION__`, `__ENVIRONMENT__`.
- `server/pages/templates/surfaces/settings.html` — server-rendered DOM
replaced with `<div id="settings-mount">`. Bridge section old JS
script tags retained for deferred sections. Injects `__SECTION__`,
`__PAGE_DATA__`.
- `src/js/sw/sdk/index.js``sw.toast` wiring (dynamic import),
`sw._sdk` bumped to `'0.37.5'`. `?v={{.Version}}` on SDK + surface
imports for cache busting (vendor imports stay bare to avoid
dual-instance).
### Known Issues (must resolve before tag)
These are tracked for future changesets — every feature must work by
the time we reach v0.37.# tag.
1. **Bridge sections stuck on "Loading..."** — Workflows and Git Keys
bridge sections render their mount `<div>` but the old JS loaders
(`loadTeamWorkflows`, `_loadSettingsGitKeys`) depend on globals
(`UI`, `API`, `App`) from `base.html`'s old script chain that aren't
loaded on the new Preact settings surface. **Resolution:** rewrite
these as full Preact components in a future changeset, or ensure the
old globals are available via a compatibility shim.
2. **Tasks bridge works, Data & Privacy bridge works** — these old JS
files load successfully because their loaders use the `sw.*` SDK or
simpler DOM patterns that don't depend on the old `UI`/`API` globals.
3. **Personas section** — nav gating works (hidden when policy disabled).
The section component itself is thin (list + create stub). Full
persona CRUD with KB bindings deferred.
4. **BYOK sections** — Providers, Roles, Usage nav items correctly
hidden when `allow_user_byok` is false. Not yet tested with BYOK
enabled (requires admin enabling the policy + configuring a provider).
5. **`pages.js` not deleted** — still contains admin surface functions
(`Pages.saveRole`, `Pages.saveProvider`, etc.) used by the admin
surface (v0.37.6). Login/settings functions in `pages.js` are now
dead code but kept until admin surface rewrite.
6. **`ui-settings.js` + `settings-handlers.js` not yet deleted** — the
old code is superseded by the Preact surface but some bridge sections
may still reference helpers from these files. Will delete after
confirming no remaining references in a dedicated cleanup pass.
### Deleted
- (Deferred — old settings JS files retained until bridge sections are
fully replaced. See Known Issues #6.)
## [0.36.0] — 2026-03-20 ## [0.36.0] — 2026-03-20
### Summary ### Summary

View File

@@ -1 +1 @@
0.36.0 0.37.5

View File

@@ -565,40 +565,71 @@ Preact+htm component library and application frame.
- [x] `dev.html` — interactive primitives gallery + shell controls - [x] `dev.html` — interactive primitives gallery + shell controls
- [x] ES module loading with `window.preact/hooks/html` globals - [x] ES module loading with `window.preact/hooks/html` globals
### v0.37.3 — Layer 1: SDK ### v0.37.3 — Layer 1: SDK
Namespaced REST client, auth module, RBAC gate, event bus, pipe/filter Namespaced REST client, auth module, RBAC gate, event bus, pipe/filter
pipeline, theme control. pipeline, theme control.
Depends on: v0.37.2. Depends on: v0.37.2.
- [ ] `sw.api.{domain}.*` — namespaced methods for all 18 ICD domains - [x] `sw.api.{domain}.*` — 18 namespaced domain clients (auth,
- [ ] `sw.auth.*` — login, logout, refresh, session lifecycle channels, personas, knowledge, notes, projects, workspaces,
- [ ] `sw.can(perm)` — synchronous RBAC gate from cached permission set memory, models, providers, notifications, extensions, profile,
- [ ] `sw.on/off/emit` — event bus with wildcard patterns teams, workflows, tasks, surfaces, admin)
- [ ] `sw.pipe.*` — carry forward pre-send/stream/render pipeline - [x] `sw.auth.*` — login, logout, refresh, session lifecycle,
- [ ] `sw.theme.*` — theme control with FOUC prevention permissions/teams/policies cache
- [x] `sw.can(perm)` — synchronous RBAC gate from cached permission set
- [x] `sw.on/off/emit` — event bus with wildcard patterns, WebSocket
connection with ticket-first auth
- [x] `sw.pipe.*` — carry forward pre-send/stream/render pipeline
- [x] `sw.theme.*` — theme control with FOUC prevention
### v0.37.4 — Layer 2: Shell ### v0.37.4 — Layer 2: Shell
Application frame with user menu, surface viewport, banners, toast/dialog Application frame with user menu, surface viewport, banners, toast/dialog
stacks. Temp auth bypass for visual validation. stacks. Temp auth bypass for visual validation.
Depends on: v0.37.3. Depends on: v0.37.3.
- [ ] Root `<App>` component tree - [x] Root `<App>` component tree (AppShell + SurfaceViewport +
- [ ] `UserMenu` — avatar + flyout (Settings, Admin, Sign Out) ToastContainer + DialogStack)
- [ ] `SurfaceViewport` — mount point for active surface - [x] `UserMenu` — avatar + flyout (Settings, Admin, Team Admin,
- [ ] `ToastContainer` + `DialogStack` — global overlay management Debug, Sign Out) with RBAC gating
- [ ] Simplified `base.html` Go template (single `#app` mount) - [x] `SurfaceViewport` — mount point for active surface
- [x] `ToastContainer` + `DialogStack` — global overlay management
- [x] `sw.userMenu(container, opts)` — imperative mount helper for
non-Preact surfaces
### v0.37.5v0.37.13 — Surface Rebuilds ### v0.37.5 — Login + Settings Surfaces ✅
First surface rebuilds. Login page and Settings surface converted from
Go templates + imperative DOM JS to Preact component trees.
Depends on: v0.37.4.
- [x] Login surface — 5 components (Hero, LoginForm, RegisterForm,
SSOPanel, LoginSurface root)
- [x] Settings surface — framework with nav + section routing
- [x] 7 Preact sections: General, Appearance, Profile, Models,
Teams, Providers (stub), Personas (stub)
- [x] Bridge pattern for deferred sections (Tasks, Git Keys,
Workflows, Data & Privacy)
- [x] `sw.toast()` SDK wiring, nav gating (BYOK + Personas)
- [x] `sw-login.css` extracted from inline styles
**Known issues (tracked in CHANGELOG, resolve before v0.37.# tag):**
- Bridge sections Workflows + Git Keys stuck on "Loading..." (old JS
depends on globals not loaded in new surface)
- `ui-settings.js` + `settings-handlers.js` not yet deleted (some
bridge sections may reference helpers)
- BYOK sections not tested with policy enabled
### v0.37.6v0.37.13 — Remaining Surface Rebuilds
Each surface rebuilt as Preact component tree. Old JS deleted per surface. Each surface rebuilt as Preact component tree. Old JS deleted per surface.
| Version | Surface | Notes | | Version | Surface | Notes |
|---------|---------|-------| |---------|---------|-------|
| v0.37.5 | Login + Settings | Self-contained, no real-time |
| v0.37.6 | Admin | Largest section count, no real-time | | v0.37.6 | Admin | Largest section count, no real-time |
| v0.37.7 | Team Admin | Inherits from Admin patterns | | v0.37.7 | Team Admin | Inherits from Admin patterns |
| v0.37.8 | ChatPane | Reusable component, NOT a surface | | v0.37.8 | ChatPane | Reusable component, NOT a surface |

View File

@@ -8,398 +8,49 @@
<link rel="icon" type="image/svg+xml" href="{{.BasePath}}/favicon.svg?v={{.Version}}"> <link rel="icon" type="image/svg+xml" href="{{.BasePath}}/favicon.svg?v={{.Version}}">
<link rel="icon" type="image/x-icon" href="{{.BasePath}}/favicon.ico?v={{.Version}}"> <link rel="icon" type="image/x-icon" href="{{.BasePath}}/favicon.ico?v={{.Version}}">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600;9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600;9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{.BasePath}}/css/variables.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/sw-login.css?v={{.Version}}">
<style> <style>
:root {
--bg: #0e0e10; --bg-surface: #18181b; --bg-raised: #222227;
--border: #2e2e35; --border-light: #3a3a42;
--text: #e8e8ed; --text-2: #9898a8; --text-3: #6b6b7b;
--accent: #6c9fff; --accent-hover: #84b0ff; --accent-dim: rgba(108,159,255,0.12);
--purple: #a78bfa; --purple-dim: rgba(167,139,250,0.10);
--danger: #ef4444; --success: #22c55e;
--input-bg: #1a1a1f;
--grid-line: rgba(108,159,255,0.04);
--glow1: rgba(108,159,255,0.06); --glow2: rgba(167,139,250,0.05);
--orb: rgba(167,139,250,0.08);
--font: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--mono: 'JetBrains Mono', monospace;
--banner-h: 28px;
--banner-top-h: {{if .Banner.Visible}}var(--banner-h){{else}}0px{{end}};
--banner-bot-h: {{if .Banner.Visible}}var(--banner-h){{else}}0px{{end}};
}
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: var(--font); background: var(--bg); color: var(--text); overflow: hidden; } body { font-family: var(--font); background: var(--bg); color: var(--text); overflow: hidden; }
.login-shell { display: flex; flex-direction: column; height: 100vh; }
.login-main { display: flex; flex: 1; min-height: 0; }
/* ── Hero Panel (left) ─────────────────── */
.hero {
flex: 1; display: flex; flex-direction: column; justify-content: center;
padding: 4rem 3.5rem; position: relative; overflow: hidden;
background:
radial-gradient(ellipse 80% 60% at 30% 40%, var(--glow1), transparent),
radial-gradient(ellipse 60% 50% at 70% 70%, var(--glow2), transparent),
var(--bg);
}
.hero-grid {
position: absolute; inset: -50%;
background-image:
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 48px 48px;
animation: gridDrift 30s linear infinite;
pointer-events: none;
}
.hero-orb {
position: absolute; width: 320px; height: 320px; bottom: -80px; right: -60px;
background: radial-gradient(circle, var(--orb), transparent 70%);
border-radius: 50%; pointer-events: none;
}
.hero-content { position: relative; z-index: 1; max-width: 520px; }
.hero-brand { display: flex; align-items: center; gap: 14px; margin-bottom: 1.5rem; }
.hero-brand-icon { width: 40px; height: 40px; }
.hero-brand-text { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.03em; line-height: 1.1; }
.hero-brand-text .hl { color: var(--accent); }
.hero h1 {
font-size: 2.4rem; font-weight: 700; line-height: 1.15;
letter-spacing: -0.03em; margin-bottom: 1rem;
background: linear-gradient(135deg, var(--text) 0%, var(--text-2) 100%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.hero-sub {
font-size: 1.05rem; color: var(--text-2); line-height: 1.6;
margin-bottom: 2.5rem; max-width: 440px;
}
.hero-pills { display: flex; flex-wrap: wrap; gap: 10px; }
.hero-pill {
display: inline-flex; align-items: center; gap: 7px;
padding: 7px 14px; background: var(--bg-surface);
border-radius: 100px; font-size: 0.8rem; font-weight: 500;
transition: all 0.15s;
}
.hero-pill.ac { border: 1px solid rgba(108,159,255,0.2); color: var(--accent); }
.hero-pill.pu { border: 1px solid rgba(167,139,250,0.2); color: var(--purple); }
.hero-pill-icon { font-size: 0.95rem; line-height: 1; }
.hero-version {
margin-top: 3rem; font-size: 0.72rem; font-family: var(--mono);
color: var(--text-3); letter-spacing: 0.04em;
}
/* ── Auth Panel (right) ────────────────── */
.auth-panel {
width: 440px; flex-shrink: 0; display: flex; align-items: center;
justify-content: center; padding: 2rem; background: var(--bg-surface);
border-left: 1px solid var(--border); overflow-y: auto;
}
.auth-inner { width: 100%; max-width: 340px; }
.auth-heading { margin-bottom: 2rem; }
.auth-heading h2 { font-size: 1.25rem; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 4px; }
.auth-heading p { font-size: 0.85rem; color: var(--text-3); }
.auth-tabs { display: flex; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border); }
.auth-tab {
flex: 1; background: none; border: none; border-bottom: 2px solid transparent;
color: var(--text-3); padding: 10px 0; cursor: pointer; font-family: var(--font);
font-size: 0.85rem; font-weight: 500; transition: color 0.15s;
}
.auth-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.auth-tab:hover { color: var(--text-2); }
.form-field { margin-bottom: 16px; }
.form-field label { display: block; font-size: 13px; font-weight: 500; color: var(--text-2); margin-bottom: 6px; }
.form-field input {
width: 100%; box-sizing: border-box; background: var(--input-bg);
border: 1px solid var(--border); color: var(--text);
padding: 10px 14px; border-radius: 10px; font-size: 14px; font-family: var(--font);
outline: none; transition: border-color 0.15s, box-shadow 0.15s;
}
.form-field input:focus {
border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim);
}
.auth-error { color: var(--danger); font-size: 0.78rem; margin: 0 0 0.5rem; }
.btn-login {
width: 100%; background: var(--accent); color: #fff; border: none;
padding: 10px 18px; font-size: 14px; border-radius: 10px; cursor: pointer;
font-family: var(--font); font-weight: 600; letter-spacing: -0.01em;
transition: all 0.18s; margin-top: 1.5rem;
}
.btn-login:hover { background: var(--accent-hover); }
.btn-login:disabled { opacity: 0.55; cursor: not-allowed; }
.auth-footer {
margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border);
text-align: center;
}
.auth-footer p { font-size: 0.75rem; color: var(--text-3); line-height: 1.6; }
/* ── Banner ─────────────────────────────── */
.login-banner {
text-align: center; font-size: 11px; font-weight: 700;
letter-spacing: 1.2px; padding: 3px 0; text-transform: uppercase; flex-shrink: 0;
}
/* ── Animations ─────────────────────────── */
@keyframes gridDrift { to { transform: translate(48px, 48px); } }
@keyframes fadeUp { to { opacity: 1; transform: translateY(0); } }
.anim { opacity: 0; transform: translateY(12px); animation: fadeUp 0.5s ease forwards; }
.anim-1 { animation-delay: .05s; } .anim-2 { animation-delay: .12s; }
.anim-3 { animation-delay: .19s; } .anim-4 { animation-delay: .26s; }
.anim-5 { animation-delay: .33s; }
.anim-a1 { animation-delay: .15s; } .anim-a2 { animation-delay: .22s; }
.anim-a3 { animation-delay: .29s; }
/* ── Responsive ─────────────────────────── */
@media (max-width: 900px) {
.hero { display: none; }
.auth-panel { width: 100%; border-left: none; }
}
::-webkit-scrollbar { width: 5px; } ::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { border-radius: 3px; background: var(--border); } ::-webkit-scrollbar-thumb { border-radius: 3px; background: var(--border); }
</style> </style>
</head> </head>
<body> <body>
<div class="login-shell"> <div id="login-mount"></div>
{{if .Banner.Visible}}
<div class="login-banner" style="background:{{.Banner.Background}};color:{{.Banner.Color}};">
{{.Banner.Text}}
</div>
{{end}}
<div class="login-main">
<!-- ═══ HERO (left) ═══ -->
<div class="hero">
<div class="hero-grid"></div>
<div class="hero-orb"></div>
<div class="hero-content">
<div class="hero-brand anim anim-1">
<img class="hero-brand-icon" src="{{.BasePath}}/favicon.svg?v={{.Version}}" alt="Chat Switchboard">
<span class="hero-brand-text">Chat <span class="hl">Switchboard</span></span>
</div>
<h1 class="anim anim-2">Your AI conversations,<br>your infrastructure</h1>
<p class="hero-sub anim anim-3">
Self-hosted multi-provider chat with team collaboration, BYOK privacy, and an extensible plugin system.
</p>
<div class="hero-pills anim anim-4">
<span class="hero-pill ac"><span class="hero-pill-icon">&#x1F916;</span> Multi-Provider AI</span>
<span class="hero-pill pu"><span class="hero-pill-icon">&#x1F511;</span> Bring Your Own Key</span>
<span class="hero-pill ac"><span class="hero-pill-icon">&#x1F465;</span> Teams &amp; Projects</span>
<span class="hero-pill pu"><span class="hero-pill-icon">&#x1F9E9;</span> Extensions</span>
<span class="hero-pill ac"><span class="hero-pill-icon">&#x1F50D;</span> Web Search Tools</span>
<span class="hero-pill pu"><span class="hero-pill-icon">&#x1F4DD;</span> Notes &amp; Memory</span>
</div>
<p class="hero-version anim anim-5">v{{.Version}}{{if .Environment}} &middot; {{.Environment}}{{end}}</p>
</div>
</div>
<!-- ═══ AUTH (right) ═══ -->
<div class="auth-panel">
<div class="auth-inner">
<div class="auth-heading anim anim-a1">
<h2 id="authTitle">Welcome back</h2>
<p id="authSubtitle">Sign in to your Switchboard instance</p>
</div>
{{if eq .AuthMode "oidc"}}
<!-- SSO LOGIN -->
<div class="anim anim-a2">
<p style="color: var(--text-2); font-size: 0.9rem; margin-bottom: 1.5rem;">
This instance uses Single Sign-On. Click below to authenticate with your identity provider.
</p>
<a class="btn-login" href="{{.BasePath}}/api/v1/auth/oidc/login" style="display:block; text-align:center; text-decoration:none;">
Sign in with SSO
</a>
</div>
{{else if eq .AuthMode "mtls"}}
<!-- mTLS LOGIN -->
<div class="anim anim-a2">
<p style="color: var(--text-2); font-size: 0.9rem; margin-bottom: 1.5rem;">
This instance uses client certificate authentication. Ensure your browser has a valid certificate installed.
</p>
<a class="btn-login" href="{{.BasePath}}/" style="display:block; text-align:center; text-decoration:none;">
Continue with Certificate
</a>
</div>
{{else}}
<!-- BUILTIN LOGIN -->
<div class="auth-tabs anim anim-a2">
<button class="auth-tab active" id="tabLogin" onclick="_switchTab('login')">Log In</button>
{{if .RegistrationOpen}}<button class="auth-tab" id="tabRegister" onclick="_switchTab('register')">Register</button>{{end}}
</div>
<!-- LOGIN FORM -->
<div id="loginForm" class="anim anim-a3">
<div class="form-field">
<label for="loginUsername">Username</label>
<input type="text" id="loginUsername" placeholder="Enter username" autocomplete="username" autofocus>
</div>
<div class="form-field">
<label for="loginPassword">Password</label>
<input type="password" id="loginPassword" placeholder="Enter password" autocomplete="current-password">
</div>
<p class="auth-error" id="loginError" style="display:none;"></p>
<button class="btn-login" id="loginBtn" onclick="Pages.doLogin()">Log In</button>
</div>
<!-- REGISTER FORM -->
{{if .RegistrationOpen}}
<div id="registerForm" style="display:none;">
<div class="form-field">
<label for="regUsername">Username</label>
<input type="text" id="regUsername" placeholder="Choose a username" autocomplete="username">
</div>
<div class="form-field">
<label for="regEmail">Email</label>
<input type="email" id="regEmail" placeholder="you@example.com" autocomplete="email">
</div>
<div class="form-field">
<label for="regPassword">Password</label>
<input type="password" id="regPassword" placeholder="Min 8 characters" autocomplete="new-password">
</div>
<div class="form-field">
<label for="regConfirm">Confirm Password</label>
<input type="password" id="regConfirm" placeholder="Re-enter password" autocomplete="new-password">
</div>
<p class="auth-error" id="regError" style="display:none;"></p>
<button class="btn-login" id="regBtn" onclick="_doRegister()">Create Account</button>
</div>
{{end}}
{{end}}{{/* end auth mode conditional */}}
<div class="auth-footer">
<p>Self-hosted instance &middot; Your data stays on your infrastructure</p>
</div>
</div>
</div>
</div>
{{if .Banner.Visible}}
<div class="login-banner" style="background:{{.Banner.Background}};color:{{.Banner.Color}};">
{{.Banner.Text}}
</div>
{{end}}
</div>
<script> <script>
window.__BASE__ = '{{.BasePath}}'; window.__BASE__ = '{{.BasePath}}';
window.__AUTH_MODE__ = '{{.AuthMode}}';
window.__REGISTRATION_OPEN__ = {{if .RegistrationOpen}}true{{else}}false{{end}};
window.__VERSION__ = '{{.Version}}';
window.__ENVIRONMENT__ = {{if .Environment}}'{{.Environment}}'{{else}}null{{end}};
window.__BANNER__ = {{if .Banner.Visible}}{
visible: true,
text: '{{.Banner.Text}}',
background: '{{.Banner.Background}}',
color: '{{.Banner.Color}}'
}{{else}}null{{end}};
</script> </script>
<script src="{{.BasePath}}/js/pages.js?v={{.Version}}"></script>
<script>
// ── OIDC SSO callback handler ───────────────
// The OIDC callback redirects here with #oidc_result=<base64 JSON>.
// We decode it, save to localStorage (same format as builtin login),
// set the page-auth cookie, and redirect to the app.
(function() {
const hash = window.location.hash;
if (!hash.startsWith('#oidc_result=')) return;
try {
const encoded = hash.slice('#oidc_result='.length);
const json = atob(encoded);
const data = JSON.parse(json);
const base = window.__BASE__ || '';
const storageKey = base ? 'sb_auth_' + base.replace(/\//g, '') : 'sb_auth';
localStorage.setItem(storageKey, JSON.stringify({
accessToken: data.access_token,
refreshToken: data.refresh_token,
user: data.user,
}));
document.cookie = 'sb_token=' + data.access_token + '; path=/; max-age=900; SameSite=Strict';
window.location.replace(base + '/');
} catch (e) {
console.error('[oidc] failed to process SSO callback:', e);
}
})();
</script>
<script>
// Builtin auth handlers — safe to include always; no-op if DOM elements absent.
function _switchTab(tab) {
const lf = document.getElementById('loginForm');
if (lf) lf.style.display = tab === 'login' ? '' : 'none';
const regForm = document.getElementById('registerForm');
if (regForm) regForm.style.display = tab === 'register' ? '' : 'none';
document.getElementById('tabLogin')?.classList.toggle('active', tab === 'login');
document.getElementById('tabRegister')?.classList.toggle('active', tab === 'register');
const title = document.getElementById('authTitle');
if (title) title.textContent = tab === 'login' ? 'Welcome back' : 'Create account';
const sub = document.getElementById('authSubtitle');
if (sub) sub.textContent = tab === 'login'
? 'Sign in to your Switchboard instance'
: 'Join your team on Switchboard';
const loginErr = document.getElementById('loginError');
if (loginErr) loginErr.textContent = '';
const regErr = document.getElementById('regError');
if (regErr) { regErr.textContent = ''; regErr.style.display = 'none'; }
}
async function _doRegister() { {{/* Vendor: Preact + htm → globals (window.preact, window.hooks, window.html) */}}
const username = document.getElementById('regUsername')?.value.trim(); <script type="module">
const email = document.getElementById('regEmail')?.value.trim(); const { h, render } = await import('{{.BasePath}}/js/sw/vendor/preact.module.js');
const password = document.getElementById('regPassword')?.value; const hooksModule = await import('{{.BasePath}}/js/sw/vendor/hooks.module.js');
const confirm = document.getElementById('regConfirm')?.value; const { default: htm } = await import('{{.BasePath}}/js/sw/vendor/htm.module.js');
const errEl = document.getElementById('regError'); const html = htm.bind(h);
const btn = document.getElementById('regBtn'); window.preact = { h, render };
if (!errEl || !btn) return; window.hooks = hooksModule;
window.html = html;
if (!username || !password) { errEl.textContent = 'Username and password are required'; errEl.style.display = ''; return; } {{/* SDK boot → window.sw */}}
if (password.length < 8) { errEl.textContent = 'Password must be at least 8 characters'; errEl.style.display = ''; return; } const { boot } = await import('{{.BasePath}}/js/sw/sdk/index.js?v={{.Version}}');
if (password !== confirm) { errEl.textContent = 'Passwords do not match'; errEl.style.display = ''; return; } await boot();
errEl.style.display = 'none'; {{/* Login surface */}}
errEl.textContent = ''; await import('{{.BasePath}}/js/sw/surfaces/login/index.js?v={{.Version}}');
btn.disabled = true; btn.textContent = 'Creating account\u2026';
const base = window.__BASE__ || '';
try {
const resp = await fetch(base + '/api/v1/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email, password }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || 'Registration failed');
}
const data = await resp.json();
if (data.pending) {
errEl.textContent = 'Account created — pending admin approval.';
errEl.style.color = 'var(--success)';
errEl.style.display = '';
setTimeout(() => { errEl.style.color = ''; errEl.style.display = 'none'; _switchTab('login'); }, 3000);
} else {
const storageKey = base ? 'sb_auth_' + base.replace(/\//g, '') : 'sb_auth';
localStorage.setItem(storageKey, JSON.stringify({
accessToken: data.access_token, refreshToken: data.refresh_token, user: data.user,
}));
document.cookie = 'sb_token=' + data.access_token + '; path=/; max-age=900; SameSite=Strict';
window.location.href = base + '/';
}
} catch (e) {
errEl.textContent = e.message;
errEl.style.display = '';
} finally {
btn.disabled = false; btn.textContent = 'Create Account';
}
}
document.getElementById('loginPassword')?.addEventListener('keydown', e => {
if (e.key === 'Enter') Pages.doLogin();
});
document.getElementById('loginUsername')?.addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('loginPassword')?.focus();
});
document.getElementById('regConfirm')?.addEventListener('keydown', e => {
if (e.key === 'Enter') _doRegister();
});
</script> </script>
</body> </body>
</html> </html>

View File

@@ -1,181 +1,27 @@
{{/* {{/*
Settings surface — matches switchboard-prototype-settings.jsx. Settings surface — v0.37.5: Preact component tree.
Full-page layout: topbar + left nav + content area. Replaces server-rendered DOM with a mount point.
JS (settings-handlers.js, ui-settings.js) populates dynamic sections. Bridge sections (tasks, gitkeys, workflows, data) still load old JS.
*/}} */}}
{{define "surface-settings"}} {{define "surface-settings"}}
<div class="surface-settings" style="flex-direction:column;"> <div id="settings-mount" class="surface-settings" style="flex-direction:column;">
{{/* Preact renders into this div. Show a loading state until it mounts. */}}
{{/* Top Bar */}} <div class="settings-placeholder" style="padding:40px;text-align:center;">Loading settings&hellip;</div>
<div class="settings-topbar">
<a href="{{.BasePath}}/" class="settings-topbar-back" id="settingsBackBtn">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
Back
</a>
<div class="settings-topbar-sep"></div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--text-2)"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg>
<span class="settings-topbar-title">Settings</span>
</div>
<div style="display:flex;flex:1;min-height:0;">
{{/* Left Nav */}}
<div class="settings-nav">
{{$section := .Section}}
{{$base := .BasePath}}
<a href="{{$base}}/settings/general" class="settings-nav-link{{if eq $section "general"}} active{{end}}">General</a>
<a href="{{$base}}/settings/appearance" class="settings-nav-link{{if eq $section "appearance"}} active{{end}}">Appearance</a>
<a href="{{$base}}/settings/models" class="settings-nav-link{{if eq $section "models"}} active{{end}}">Models</a>
<a href="{{$base}}/settings/personas" class="settings-nav-link{{if eq $section "personas"}} active{{end}}">Personas</a>
<a href="{{$base}}/settings/profile" class="settings-nav-link{{if eq $section "profile"}} active{{end}}">Profile</a>
<a href="{{$base}}/settings/teams" class="settings-nav-link{{if eq $section "teams"}} active{{end}}">Teams</a>
<a href="{{$base}}/settings/workflows" class="settings-nav-link{{if eq $section "workflows"}} active{{end}}">Workflows</a>
<a href="{{$base}}/settings/tasks" class="settings-nav-link{{if eq $section "tasks"}} active{{end}}">Tasks</a>
<a href="{{$base}}/settings/gitkeys" class="settings-nav-link{{if eq $section "gitkeys"}} active{{end}}">Git Keys</a>
<a href="{{$base}}/settings/data" class="settings-nav-link{{if eq $section "data"}} active{{end}}">Data &amp; Privacy</a>
{{/* BYOK-gated nav items */}}
<div id="settingsByokNav" style="display:none;">
<div class="settings-nav-sep"></div>
<a href="{{$base}}/settings/providers" class="settings-nav-link{{if eq $section "providers"}} active{{end}}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
My Providers
</a>
<a href="{{$base}}/settings/roles" class="settings-nav-link{{if eq $section "roles"}} active{{end}}">Model Roles</a>
<a href="{{$base}}/settings/usage" class="settings-nav-link{{if eq $section "usage"}} active{{end}}">My Usage</a>
</div>
{{/* BYOK indicator */}}
<div id="settingsByokIndicator" class="settings-nav-footer" style="display:none;">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--success)" stroke-width="2"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
<span style="font-size:11px;font-weight:600;color:var(--success);">BYOK Enabled</span>
</div>
<p style="font-size:10px;color:var(--text-3);margin:0;line-height:1.5;">Admin has enabled Bring Your Own Key</p>
</div>
</div>
{{/* Content */}}
<div class="settings-content" id="settingsContentMount">
<div id="settingsSection" data-section="{{.Section}}">
<h2 id="settingsSectionTitle">{{if eq .Section "general"}}General{{else if eq .Section "appearance"}}Appearance{{else if eq .Section "models"}}Models{{else if eq .Section "personas"}}Personas{{else if eq .Section "profile"}}Profile{{else if eq .Section "teams"}}Teams{{else if eq .Section "workflows"}}Workflows{{else if eq .Section "tasks"}}Tasks{{else if eq .Section "providers"}}My Providers{{else if eq .Section "roles"}}Model Roles{{else if eq .Section "usage"}}My Usage{{else if eq .Section "knowledge"}}Knowledge Bases{{else if eq .Section "memory"}}Memory{{else if eq .Section "notifications"}}Notifications{{else if eq .Section "data"}}Data &amp; Privacy{{else}}Settings{{end}}</h2>
{{if eq .Section "general"}}
<div class="settings-section">
<h3>Chat Defaults</h3>
<div class="form-group">
<label>Default Model</label>
<select id="settingsModel"></select>
</div>
<div class="form-group">
<label>System Prompt</label>
<textarea id="settingsSystemPrompt" rows="3" placeholder="Optional system prompt…" style="max-width:100%;"></textarea>
</div>
<div style="display:flex;gap:16px;">
<div class="form-group" style="flex:1;">
<label>Max Tokens <span id="settingsMaxHint" style="font-weight:400;color:var(--text-3);"></span></label>
<input type="number" id="settingsMaxTokens" placeholder="default">
</div>
<div class="form-group" style="flex:1;">
<label>Temperature</label>
<div style="display:flex;align-items:center;gap:8px;">
<input type="range" id="settingsTemp" min="0" max="2" step="0.1" value="0.7" style="flex:1;">
<span id="settingsTempValue" style="font-size:12px;color:var(--text-2);font-family:var(--mono);">0.7</span>
</div>
</div>
</div>
<label style="display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text-2);cursor:pointer;margin-top:4px;">
<input type="checkbox" id="settingsThinking"> Show thinking blocks
</label>
</div>
{{else if eq .Section "appearance"}}
<div class="settings-section">
<h3>Theme</h3>
<div id="themeToggle" class="toggle-group">
<button class="toggle-btn" data-theme="light">&#9728; Light</button>
<button class="toggle-btn" data-theme="dark">&#9790; Dark</button>
<button class="toggle-btn" data-theme="system">&#8862; System</button>
</div>
</div>
<div class="settings-section">
<h3>UI Scale</h3>
<div class="form-group">
<div style="display:flex;align-items:center;gap:8px;">
<input type="range" id="settingsScale" min="80" max="175" step="5" value="100" style="flex:1;">
<span id="scaleValue" style="font-size:12px;color:var(--text-2);font-family:var(--mono);min-width:32px;">100%</span>
</div>
</div>
<h3 style="margin-top:16px;">Message Font Size</h3>
<div class="form-group">
<div style="display:flex;align-items:center;gap:8px;">
<input type="range" id="settingsMsgFont" min="12" max="20" step="1" value="14" style="flex:1;">
<span id="msgFontValue" style="font-size:12px;color:var(--text-2);font-family:var(--mono);min-width:32px;">14px</span>
</div>
</div>
</div>
<button class="btn-md btn-primary" onclick="sb.call('UI.saveAppearance')">Save</button>
{{else if eq .Section "profile"}}
<div class="settings-section">
<h3>Profile</h3>
<div class="form-group"><label>Display Name</label><input type="text" id="profileDisplayName" placeholder="Your display name"></div>
<div class="form-group"><label>Email</label><input type="email" id="profileEmail" disabled></div>
<button class="btn-md btn-primary" onclick="sb.call('Pages.saveProfile')">Save Profile</button>
</div>
<div class="settings-section" style="margin-top:20px;">
<h3>Change Password</h3>
<div class="form-group"><label>Current Password</label><input type="password" id="settingsCurrentPw" autocomplete="current-password"></div>
<div class="form-group"><label>New Password</label><input type="password" id="settingsNewPw" autocomplete="new-password"></div>
<div class="form-group"><label>Confirm New Password</label><input type="password" id="settingsConfirmPw" autocomplete="new-password"></div>
<button class="btn-md btn-primary" onclick="sb.call('Pages.changePassword')">Update Password</button>
</div>
{{else if eq .Section "providers"}}
<div id="userProvidersDisabled" style="display:none;">
<div class="settings-section"><p style="color:var(--text-2);font-size:13px;">Your admin has disabled user-managed API keys (BYOK).</p></div>
</div>
<div style="margin-bottom:12px;">
<button class="btn-md btn-primary" id="providerShowAddBtn" style="display:none;">+ Add Provider</button>
</div>
<div id="providerAddForm" style="display:none;"></div>
<div id="providerList"><div class="settings-placeholder">Loading providers…</div></div>
{{else if eq .Section "personas"}}
<div style="margin-bottom:12px;">
<button class="btn-md btn-primary" id="userAddPersonaBtn" style="display:none;">+ New Persona</button>
</div>
<div id="userAddPersonaForm" style="display:none;"></div>
<div id="userPersonaList"><div class="settings-placeholder">Loading personas…</div></div>
{{else if eq .Section "models"}}
<div id="userModelList"><div class="settings-placeholder">Loading models…</div></div>
{{else if eq .Section "roles"}}
<div id="userRolesDisabled" class="settings-section" style="display:none;">
<p style="color:var(--text-2);font-size:13px;">Add a personal provider first to configure model role overrides.</p>
</div>
<div id="userRolesConfig"></div>
{{else if eq .Section "usage"}}
<div id="myUsageTotals"></div>
{{else if eq .Section "teams"}}
<div id="settingsDynamic"><div class="settings-placeholder">Loading teams…</div></div>
{{else if eq .Section "workflows"}}
<div id="settingsDynamic"><div class="settings-placeholder">Loading workflows…</div></div>
{{else if eq .Section "tasks"}}
<div id="settingsTasksMount"><div class="settings-placeholder">Loading tasks…</div></div>
{{else if eq .Section "data"}}
<div id="dpMount"><div class="settings-placeholder">Loading…</div></div>
{{else}}<div class="settings-placeholder" id="settingsDynamic">Loading…</div>
{{end}}
</div>
</div>
</div>
</div> </div>
{{end}} {{end}}
{{/* CSS: loaded via base.html (variables, layout, primitives, modals, chat, panels, surfaces, splash) */}} {{/* CSS: loaded via base.html (variables, layout, primitives, modals, surfaces) */}}
{{define "css-settings"}}{{end}} {{define "css-settings"}}{{end}}
{{/* Scripts */}} {{/* Scripts */}}
{{define "scripts-settings"}} {{define "scripts-settings"}}
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-settings.js?v={{.Version}}"></script> {{/* Inject section name for Preact routing */}}
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/settings-handlers.js?v={{.Version}}"></script> <script nonce="{{.CSPNonce}}">
window.__SECTION__ = '{{.Section}}';
</script>
{{/* Bridge section JS — deferred sections still use old imperative code */}}
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/task-settings.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/task-settings.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/git-credentials-ui.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/git-credentials-ui.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/knowledge-ui.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/knowledge-ui.js?v={{.Version}}"></script>
@@ -185,103 +31,22 @@
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notification-prefs.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notification-prefs.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/channel-models.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/channel-models.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/data-portability.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/data-portability.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}">
document.addEventListener('DOMContentLoaded', async () => {
const section = document.getElementById('settingsSection')?.dataset.section;
if (!section) return;
// ── Settings navigation: don't pollute browser history ── {{/* Vendor: Preact + htm → globals, then SDK boot, then settings surface */}}
// On first settings page load, stash the URL the user came from. <script type="module" nonce="{{.CSPNonce}}">
// Tab switches use location.replace() so they don't stack in history. const { h, render } = await import('{{.BasePath}}/js/sw/vendor/preact.module.js');
// Back button navigates directly to the stored return URL. const hooksModule = await import('{{.BasePath}}/js/sw/vendor/hooks.module.js');
const RETURN_KEY = 'sb_settings_return'; const { default: htm } = await import('{{.BasePath}}/js/sw/vendor/htm.module.js');
if (!sessionStorage.getItem(RETURN_KEY)) { const html = htm.bind(h);
// Prefer referrer if it's on the same origin and not another settings page window.preact = window.preact || { h, render };
const ref = document.referrer; window.hooks = window.hooks || hooksModule;
const base = (window.__BASE__ || '') + '/'; window.html = window.html || html;
if (ref && ref.startsWith(location.origin) && !ref.includes('/settings')) {
sessionStorage.setItem(RETURN_KEY, ref);
} else {
sessionStorage.setItem(RETURN_KEY, location.origin + base);
}
}
const backBtn = document.getElementById('settingsBackBtn'); // Boot SDK (idempotent — may already be loaded by base.html in future)
if (backBtn) { const { boot } = await import('{{.BasePath}}/js/sw/sdk/index.js?v={{.Version}}');
backBtn.addEventListener('click', (e) => { await boot();
e.preventDefault();
const returnURL = sessionStorage.getItem(RETURN_KEY);
sessionStorage.removeItem(RETURN_KEY);
location.href = returnURL || (window.__BASE__ || '') + '/';
});
}
// Tab links: replace current history entry instead of pushing // Mount settings surface
document.querySelectorAll('.settings-nav-link').forEach(link => { await import('{{.BasePath}}/js/sw/surfaces/settings/index.js?v={{.Version}}');
link.addEventListener('click', (e) => {
e.preventDefault();
location.replace(link.href);
});
});
// Show BYOK nav if enabled
const pageData = window.__PAGE_DATA__;
if (pageData && pageData.BYOKEnabled) {
const byokNav = document.getElementById('settingsByokNav');
const byokInd = document.getElementById('settingsByokIndicator');
if (byokNav) byokNav.style.display = '';
if (byokInd) byokInd.style.display = '';
}
if (section === 'appearance' && typeof UI !== 'undefined') {
UI.loadAppearanceSettings?.();
UI.initAppearance?.();
}
if (section === 'general' && typeof UI !== 'undefined') {
UI.loadGeneralSettings?.();
}
if (section === 'profile') {
const user = window.__USER__;
if (user) {
const dn = document.getElementById('profileDisplayName');
const em = document.getElementById('profileEmail');
if (dn) dn.value = user.display_name || user.username || '';
if (em) em.value = user.email || '';
}
}
const dynamicSections = {
providers: () => typeof UI !== 'undefined' && UI.loadProviderList?.(),
models: () => typeof UI !== 'undefined' && UI.loadUserModels?.(),
personas: () => typeof UI !== 'undefined' && UI.loadUserPersonas?.(),
usage: () => typeof UI !== 'undefined' && UI.loadMyUsage?.(),
roles: () => typeof UI !== 'undefined' && UI.loadUserRoles?.(),
knowledge: () => typeof KnowledgeUI !== 'undefined' && KnowledgeUI.openManagePanel?.(),
memory: () => typeof MemoryUI !== 'undefined' && MemoryUI.openSettingsPanel?.(),
notifications: () => typeof NotifPrefs !== 'undefined' && NotifPrefs.load?.(),
teams: () => typeof UI !== 'undefined' && UI.loadTeamsSettings?.(),
workflows: () => typeof loadTeamWorkflows === 'function' && loadTeamWorkflows(),
tasks: () => typeof _loadSettingsTasks === 'function' && _loadSettingsTasks(),
gitkeys: () => typeof _loadSettingsGitKeys === 'function' && _loadSettingsGitKeys(),
data: () => typeof loadDataPrivacy === 'function' && loadDataPrivacy(),
};
// Populate App.policies and App.models from API.
// On chat surface, app.js does this. On settings surface, we must do it here.
try {
const pubData = await API.getPublicSettings();
App.policies = pubData.policies || {};
} catch (_) { App.policies = App.policies || {}; }
if (typeof fetchModels === 'function') await fetchModels();
// Wire up interactive handlers (BYOK provider form, model visibility, etc.)
if (typeof _initSettingsListeners === 'function') _initSettingsListeners();
// Show/hide BYOK and persona features based on policies
if (typeof UI !== 'undefined') {
UI.checkUserProvidersAllowed?.();
UI.checkUserPersonasAllowed?.();
}
if (dynamicSections[section]) dynamicSections[section]();
});
</script> </script>
{{end}} {{end}}

View File

@@ -1,3 +1,3 @@
package main package main
const Version = "0.36.0" const Version = "0.37.5"

157
src/css/sw-login.css Normal file
View File

@@ -0,0 +1,157 @@
/* ── sw-login.css ─────────────────────────────
Login surface styles.
Variables from variables.css are available.
──────────────────────────────────────────── */
/* ── Layout ──────────────────────────────────── */
.login-shell { display: flex; flex-direction: column; height: 100vh; }
.login-main { display: flex; flex: 1; min-height: 0; }
/* ── Hero Panel (left) ─────────────────────── */
.login-hero {
flex: 1; display: flex; flex-direction: column; justify-content: center;
padding: 4rem 3.5rem; position: relative; overflow: hidden;
background:
radial-gradient(ellipse 80% 60% at 30% 40%, rgba(108,159,255,0.06), transparent),
radial-gradient(ellipse 60% 50% at 70% 70%, rgba(167,139,250,0.05), transparent),
var(--bg);
}
.login-hero-grid {
position: absolute; inset: -50%;
background-image:
linear-gradient(rgba(108,159,255,0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(108,159,255,0.04) 1px, transparent 1px);
background-size: 48px 48px;
animation: loginGridDrift 30s linear infinite;
pointer-events: none;
}
.login-hero-orb {
position: absolute; width: 320px; height: 320px; bottom: -80px; right: -60px;
background: radial-gradient(circle, rgba(167,139,250,0.08), transparent 70%);
border-radius: 50%; pointer-events: none;
}
.login-hero-content { position: relative; z-index: 1; max-width: 520px; }
.login-hero-brand { display: flex; align-items: center; gap: 14px; margin-bottom: 1.5rem; }
.login-hero-brand-icon { width: 40px; height: 40px; }
.login-hero-brand-text { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.03em; line-height: 1.1; }
.login-hero-brand-text .hl { color: var(--accent); }
.login-hero h1 {
font-size: 2.4rem; font-weight: 700; line-height: 1.15;
letter-spacing: -0.03em; margin-bottom: 1rem;
background: linear-gradient(135deg, var(--text) 0%, var(--text-2) 100%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.login-hero-sub {
font-size: 1.05rem; color: var(--text-2); line-height: 1.6;
margin-bottom: 2.5rem; max-width: 440px;
}
.login-hero-pills { display: flex; flex-wrap: wrap; gap: 10px; }
.login-hero-pill {
display: inline-flex; align-items: center; gap: 7px;
padding: 7px 14px; background: var(--bg-surface);
border-radius: 100px; font-size: 0.8rem; font-weight: 500;
transition: all 0.15s;
}
.login-hero-pill.ac { border: 1px solid rgba(108,159,255,0.2); color: var(--accent); }
.login-hero-pill.pu { border: 1px solid rgba(167,139,250,0.2); color: var(--purple); }
.login-hero-pill-icon { font-size: 0.95rem; line-height: 1; }
.login-hero-version {
margin-top: 3rem; font-size: 0.72rem; font-family: var(--mono);
color: var(--text-3); letter-spacing: 0.04em;
}
/* ── Auth Panel (right) ────────────────────── */
.login-auth-panel {
width: 440px; flex-shrink: 0; display: flex; align-items: center;
justify-content: center; padding: 2rem; background: var(--bg-surface);
border-left: 1px solid var(--border); overflow-y: auto;
}
.login-auth-inner { width: 100%; max-width: 340px; }
.login-auth-heading { margin-bottom: 2rem; }
.login-auth-heading h2 { font-size: 1.25rem; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 4px; }
.login-auth-heading p { font-size: 0.85rem; color: var(--text-3); }
.login-auth-tabs { display: flex; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border); }
.login-auth-tab {
flex: 1; background: none; border: none; border-bottom: 2px solid transparent;
color: var(--text-3); padding: 10px 0; cursor: pointer; font-family: var(--font);
font-size: 0.85rem; font-weight: 500; transition: color 0.15s;
}
.login-auth-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.login-auth-tab:hover { color: var(--text-2); }
.login-form-field { margin-bottom: 16px; }
.login-form-field label {
display: block; font-size: 13px; font-weight: 500;
color: var(--text-2); margin-bottom: 6px;
}
.login-form-field input {
width: 100%; box-sizing: border-box; background: var(--input-bg);
border: 1px solid var(--border); color: var(--text);
padding: 10px 14px; border-radius: 10px; font-size: 14px; font-family: var(--font);
outline: none; transition: border-color 0.15s, box-shadow 0.15s;
}
.login-form-field input:focus {
border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim);
}
.login-auth-error {
color: var(--danger); font-size: 0.78rem; margin: 0 0 0.5rem;
}
.login-auth-error.success { color: var(--success); }
.login-btn {
width: 100%; background: var(--accent); color: #fff; border: none;
padding: 10px 18px; font-size: 14px; border-radius: 10px; cursor: pointer;
font-family: var(--font); font-weight: 600; letter-spacing: -0.01em;
transition: all 0.18s; margin-top: 1.5rem;
}
.login-btn:hover { background: var(--accent-hover); }
.login-btn:disabled { opacity: 0.55; cursor: not-allowed; }
.login-btn--link {
display: block; text-align: center; text-decoration: none;
}
.login-auth-footer {
margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border);
text-align: center;
}
.login-auth-footer p { font-size: 0.75rem; color: var(--text-3); line-height: 1.6; }
/* ── Banner ─────────────────────────────────── */
.login-banner {
text-align: center; font-size: 11px; font-weight: 700;
letter-spacing: 1.2px; padding: 3px 0; text-transform: uppercase; flex-shrink: 0;
}
/* ── SSO panel description ──────────────────── */
.login-sso-desc {
color: var(--text-2); font-size: 0.9rem; margin-bottom: 1.5rem;
}
/* ── Animations ─────────────────────────────── */
@keyframes loginGridDrift { to { transform: translate(48px, 48px); } }
@keyframes loginFadeUp { to { opacity: 1; transform: translateY(0); } }
.login-anim {
opacity: 0; transform: translateY(12px);
animation: loginFadeUp 0.5s ease forwards;
}
.login-anim-1 { animation-delay: .05s; }
.login-anim-2 { animation-delay: .12s; }
.login-anim-3 { animation-delay: .19s; }
.login-anim-4 { animation-delay: .26s; }
.login-anim-5 { animation-delay: .33s; }
.login-anim-a1 { animation-delay: .15s; }
.login-anim-a2 { animation-delay: .22s; }
.login-anim-a3 { animation-delay: .29s; }
/* ── Responsive ─────────────────────────────── */
@media (max-width: 900px) {
.login-hero { display: none; }
.login-auth-panel { width: 100%; border-left: none; }
}

View File

@@ -307,6 +307,74 @@
`; `;
} }
// ── Surfaces Demo Page ────────────────────
function SurfacesPage() {
return html`
<div class="gallery">
<h1>Surfaces Demo</h1>
<p class="subtitle">v0.37.5 — Login + Settings surfaces</p>
<div class="section">
<h2>Login Surface</h2>
<p style="color:var(--text-3); font-size:0.85rem; margin-bottom:0.75rem">
The login surface is a standalone page (not embedded in AppShell).
It renders at /login with its own layout: Hero panel + Auth panel.
</p>
<div style="font-family:var(--font-mono, monospace); font-size:0.8rem; color:var(--text-2); background:var(--bg-1); padding:0.75rem; border-radius:6px; white-space:pre;">${
`<LoginSurface>
<Hero />
<AuthPanel>
authMode === 'builtin':
<LoginForm /> — sw.auth.login()
<RegisterForm /> — sw.api.auth.register()
authMode === 'oidc' | 'mtls':
<SSOPanel />
</AuthPanel>
</LoginSurface>`
}</div>
<p style="color:var(--text-3); font-size:0.8rem; margin-top:0.5rem">
Files: src/js/sw/surfaces/login/
</p>
</div>
<div class="section">
<h2>Settings Surface</h2>
<p style="color:var(--text-3); font-size:0.85rem; margin-bottom:0.75rem">
Full Preact surface with left nav + section routing.
Core sections (General, Appearance, Profile, Teams, Models, Providers,
Personas, Roles, Usage) are native Preact components.
Deferred sections use BridgeSection wrappers.
</p>
<div style="font-family:var(--font-mono, monospace); font-size:0.8rem; color:var(--text-2); background:var(--bg-1); padding:0.75rem; border-radius:6px; white-space:pre;">${
`<SettingsSurface>
<Topbar />
<Nav>
General, Appearance, Models, Personas, Profile,
Teams, Workflows, Tasks, Git Keys, Data & Privacy
[BYOK] Providers, Roles, Usage
</Nav>
<Content>
section === 'general' → <GeneralSection />
section === 'appearance' → <AppearanceSection />
section === 'profile' → <ProfileSection />
section === 'teams' → <TeamsSection />
section === 'models' → <ModelsSection />
section === 'providers' → <ProvidersSection />
section === 'personas' → <PersonasSection />
section === 'roles' → <RolesSection />
section === 'usage' → <UsageSection />
section === 'tasks' etc → <BridgeSection /> (old JS)
</Content>
</SettingsSurface>`
}</div>
<p style="color:var(--text-3); font-size:0.8rem; margin-top:0.5rem">
Files: src/js/sw/surfaces/settings/
</p>
</div>
</div>
`;
}
function PrimitivesPage() { function PrimitivesPage() {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
@@ -579,6 +647,7 @@
<button class=${page === 'primitives' ? 'active' : ''} onClick=${() => setPage('primitives')}>Primitives</button> <button class=${page === 'primitives' ? 'active' : ''} onClick=${() => setPage('primitives')}>Primitives</button>
<button class=${page === 'shell' ? 'active' : ''} onClick=${() => setPage('shell')}>Shell</button> <button class=${page === 'shell' ? 'active' : ''} onClick=${() => setPage('shell')}>Shell</button>
<button class=${page === 'sdk' ? 'active' : ''} onClick=${() => setPage('sdk')}>SDK</button> <button class=${page === 'sdk' ? 'active' : ''} onClick=${() => setPage('sdk')}>SDK</button>
<button class=${page === 'surfaces' ? 'active' : ''} onClick=${() => setPage('surfaces')}>Surfaces</button>
<div style="flex:1" /> <div style="flex:1" />
<${ShellControls} ...${{ <${ShellControls} ...${{
bannerOn, setBannerOn, bannerVariant, setBannerVariant, bannerText, setBannerText, bannerOn, setBannerOn, bannerVariant, setBannerVariant, bannerText, setBannerText,
@@ -590,6 +659,7 @@
${page === 'primitives' && html`<${PrimitivesPage} />`} ${page === 'primitives' && html`<${PrimitivesPage} />`}
${page === 'shell' && html`<${ShellPage} />`} ${page === 'shell' && html`<${ShellPage} />`}
${page === 'sdk' && html`<${SDKPage} />`} ${page === 'sdk' && html`<${SDKPage} />`}
${page === 'surfaces' && html`<${SurfacesPage} />`}
</div> </div>
</div> </div>
<//> <//>

View File

@@ -275,20 +275,16 @@ describe('SPA bridge — admin settings handler references policy keys', () => {
// ── HTML element existence checks ──────────── // ── HTML element existence checks ────────────
// v0.22.5: Elements now live in server templates, not index.html. // v0.22.5: Elements now live in server templates, not index.html.
// Settings surface dynamic sections (providers, personas) have scaffold // v0.37.5: Settings surface elements moved to Preact components
// containers rendered by Go templates that JS then populates. // (src/js/sw/surfaces/settings/). Only admin template elements
// are checked here. Settings surface policy gating is handled by
// the Preact SettingsSurface component (nav gating + section routing).
describe('Critical HTML elements exist in server templates', () => { describe('Critical HTML elements exist in server templates', () => {
const templateSrc = readAllTemplates(); const templateSrc = readAllTemplates();
const requiredElements = [ const requiredElements = [
// Settings surface — provider section scaffold // Admin settings — policy toggles (still in Go templates)
'userPersonaList', // User persona list container
'userAddPersonaBtn', // New persona button (policy-gated)
'userAddPersonaForm', // Persona form container
'userProvidersDisabled', // BYOK disabled notice
'providerShowAddBtn', // Add provider button (policy-gated)
// Admin settings — policy toggles
'settUserBYOK', // Admin toggle for BYOK (was adminUserProvidersToggle) 'settUserBYOK', // Admin toggle for BYOK (was adminUserProvidersToggle)
'settUserPersonas', // Admin toggle for presets (was adminUserPresetsToggle) 'settUserPersonas', // Admin toggle for presets (was adminUserPresetsToggle)
]; ];
@@ -301,6 +297,35 @@ describe('Critical HTML elements exist in server templates', () => {
} }
}); });
// ── Preact surface policy gating ─────────────
// v0.37.5: Settings surface elements are now rendered by Preact
// components. Verify the components handle policy gating.
describe('Settings Preact surface handles policy gating', () => {
const SURFACES = path.join(SRC, 'sw', 'surfaces', 'settings');
const indexSrc = fs.readFileSync(path.join(SURFACES, 'index.js'), 'utf-8');
it('settings index checks allow_user_byok policy', () => {
assert.ok(indexSrc.includes('allow_user_byok'),
'MISSING: allow_user_byok check — BYOK nav items will show when disabled');
});
it('settings index checks allow_user_personas policy', () => {
assert.ok(indexSrc.includes('allow_user_personas'),
'MISSING: allow_user_personas check — Personas nav will show when disabled');
});
it('settings index has byokEnabled state', () => {
assert.ok(indexSrc.includes('byokEnabled'),
'MISSING: byokEnabled state — BYOK section gating broken');
});
it('settings index has personasEnabled state', () => {
assert.ok(indexSrc.includes('personasEnabled'),
'MISSING: personasEnabled state — Personas section gating broken');
});
});
// ── SPA bridge — dynamic DOM elements ──────── // ── SPA bridge — dynamic DOM elements ────────
// adminMemberUser is created by ui-admin.js loadMemberUserDropdown() // adminMemberUser is created by ui-admin.js loadMemberUserDropdown()
// which runs inside the SPA chat surface. Verify the JS function exists. // which runs inside the SPA chat surface. Verify the JS function exists.

View File

@@ -237,6 +237,7 @@ export function createAuth() {
window.location.href = base + '/login'; window.location.href = base + '/login';
}, },
_setAuth(data) { _setAuth(data); },
_setRestClient(client) { _restClient = client; }, _setRestClient(client) { _restClient = client; },
_setEmit(fn) { _emit = fn; }, _setEmit(fn) { _emit = fn; },
}; };

View File

@@ -93,10 +93,21 @@ export async function boot() {
sw.pipe = pipe; sw.pipe = pipe;
sw.theme = theme; sw.theme = theme;
// Shell helpers — imperative confirm/prompt backed by DialogStack // Shell helpers — imperative confirm/prompt backed by primitives
sw.confirm = confirm; sw.confirm = confirm;
sw.prompt = prompt; sw.prompt = prompt;
// Toast — dynamic import to avoid module resolution issues
try {
const toastMod = await import('../primitives/toast.js');
sw.toast = toastMod.toast;
events.on('toast', ({ message, variant, duration }) => {
toastMod.toast(message, variant, duration);
});
} catch (e) {
console.warn('[sw] Toast import failed:', e.message);
}
// UserMenu render helper — surfaces call sw.userMenu(container, opts) // UserMenu render helper — surfaces call sw.userMenu(container, opts)
sw.userMenu = function (container, opts = {}) { sw.userMenu = function (container, opts = {}) {
import('../shell/user-menu.js').then(({ UserMenu }) => { import('../shell/user-menu.js').then(({ UserMenu }) => {
@@ -106,7 +117,7 @@ export async function boot() {
}; };
// Marker for idempotency // Marker for idempotency
sw._sdk = '0.37.4'; sw._sdk = '0.37.5';
// 8. Expose globally // 8. Expose globally
window.sw = sw; window.sw = sw;
@@ -128,7 +139,7 @@ export async function boot() {
// 10. Signal ready // 10. Signal ready
events.emit('sdk.ready', {}, { localOnly: true }); events.emit('sdk.ready', {}, { localOnly: true });
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } })); document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));
console.log('[sw] SDK v0.37.4 ready'); console.log('[sw] SDK v0.37.5 ready');
return sw; return sw;
} }

View File

@@ -0,0 +1,55 @@
/**
* Hero — left panel with branding, feature pills, version
*
* Props:
* base — base path for assets (string)
* version — app version (string)
* env — environment label or null
*/
const { html } = window;
const PILLS = [
{ icon: '\u{1F916}', label: 'Multi-Provider AI', cls: 'ac' },
{ icon: '\u{1F511}', label: 'Bring Your Own Key', cls: 'pu' },
{ icon: '\u{1F465}', label: 'Teams & Projects', cls: 'ac' },
{ icon: '\u{1F9E9}', label: 'Extensions', cls: 'pu' },
{ icon: '\u{1F50D}', label: 'Web Search Tools', cls: 'ac' },
{ icon: '\u{1F4DD}', label: 'Notes & Memory', cls: 'pu' },
];
export function Hero({ base = '', version = '', env = null }) {
return html`
<div class="login-hero">
<div class="login-hero-grid"></div>
<div class="login-hero-orb"></div>
<div class="login-hero-content">
<div class="login-hero-brand login-anim login-anim-1">
<img class="login-hero-brand-icon"
src="${base}/favicon.svg?v=${version}"
alt="Chat Switchboard" />
<span class="login-hero-brand-text">
Chat <span class="hl">Switchboard</span>
</span>
</div>
<h1 class="login-anim login-anim-2">
Your AI conversations,${html`<br />`}your infrastructure
</h1>
<p class="login-hero-sub login-anim login-anim-3">
Self-hosted multi-provider chat with team collaboration,
BYOK privacy, and an extensible plugin system.
</p>
<div class="login-hero-pills login-anim login-anim-4">
${PILLS.map(p => html`
<span class="login-hero-pill ${p.cls}">
<span class="login-hero-pill-icon">${p.icon}</span>
${' '}${p.label}
</span>
`)}
</div>
<p class="login-hero-version login-anim login-anim-5">
v${version}${env ? html` \u00B7 ${env}` : ''}
</p>
</div>
</div>
`;
}

View File

@@ -0,0 +1,116 @@
/**
* LoginSurface — root login surface component
*
* Reads globals from Go template:
* __BASE__ — base path
* __AUTH_MODE__ — 'builtin' | 'oidc' | 'mtls'
* __REGISTRATION_OPEN__ — boolean
* __BANNER__ — { visible, text, background, color } or null
* __VERSION__ — app version string
* __ENVIRONMENT__ — environment label or null
*
* Handles OIDC callback (#oidc_result=<base64>).
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
const { render } = preact;
import { Hero } from './hero.js';
import { LoginForm } from './login-form.js';
import { RegisterForm } from './register-form.js';
import { SSOPanel } from './sso-panel.js';
function LoginSurface() {
const BASE = window.__BASE__ || '';
const authMode = window.__AUTH_MODE__ || 'builtin';
const regOpen = window.__REGISTRATION_OPEN__ || false;
const banner = window.__BANNER__ || null;
const version = window.__VERSION__ || '';
const env = window.__ENVIRONMENT__ || null;
const [tab, setTab] = useState('login'); // 'login' | 'register'
// ── OIDC callback handler (runs once on mount) ──
useEffect(() => {
const hash = window.location.hash;
if (!hash.startsWith('#oidc_result=')) return;
try {
const encoded = hash.slice('#oidc_result='.length);
const json = atob(encoded);
const data = JSON.parse(json);
sw.auth._setAuth(data);
window.location.replace(BASE + '/');
} catch (e) {
console.error('[login] Failed to process OIDC callback:', e);
}
}, []);
const switchToLogin = useCallback(() => setTab('login'), []);
const heading = tab === 'login'
? { title: 'Welcome back', sub: 'Sign in to your Switchboard instance' }
: { title: 'Create account', sub: 'Join your team on Switchboard' };
return html`
<div class="login-shell">
${banner?.visible && html`
<div class="login-banner"
style="background:${banner.background};color:${banner.color};">
${banner.text}
</div>
`}
<div class="login-main">
<${Hero} base=${BASE} version=${version} env=${env} />
<div class="login-auth-panel">
<div class="login-auth-inner">
<div class="login-auth-heading login-anim login-anim-a1">
<h2>${heading.title}</h2>
<p>${heading.sub}</p>
</div>
${authMode === 'oidc' || authMode === 'mtls'
? html`<${SSOPanel} mode=${authMode} base=${BASE} />`
: html`
${regOpen && html`
<div class="login-auth-tabs login-anim login-anim-a2">
<button class="login-auth-tab ${tab === 'login' ? 'active' : ''}"
onClick=${() => setTab('login')}>Log In</button>
<button class="login-auth-tab ${tab === 'register' ? 'active' : ''}"
onClick=${() => setTab('register')}>Register</button>
</div>
`}
${tab === 'login'
? html`<${LoginForm} />`
: html`<${RegisterForm} onPending=${switchToLogin} />`
}
`
}
<div class="login-auth-footer">
<p>Self-hosted instance \u00B7 Your data stays on your infrastructure</p>
</div>
</div>
</div>
</div>
${banner?.visible && html`
<div class="login-banner"
style="background:${banner.background};color:${banner.color};">
${banner.text}
</div>
`}
</div>
`;
}
// ── Mount ────────────────────────────────────
const mount = document.getElementById('login-mount');
if (mount) {
render(html`<${LoginSurface} />`, mount);
console.log('[login] Surface mounted');
}
export { LoginSurface };

View File

@@ -0,0 +1,68 @@
/**
* LoginForm — username/password form using sw.auth.login()
*
* Props:
* onSuccess — called after successful login (optional, default: redirect to /)
*/
const { html } = window;
const { useState, useRef, useCallback } = hooks;
export function LoginForm({ onSuccess }) {
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const userRef = useRef(null);
const passRef = useRef(null);
const BASE = window.__BASE__ || '';
const doLogin = useCallback(async () => {
const username = userRef.current?.value?.trim();
const password = passRef.current?.value;
if (!username || !password) {
setError('Enter username and password');
return;
}
setError('');
setLoading(true);
try {
await sw.auth.login(username, password);
if (onSuccess) {
onSuccess();
} else {
window.location.href = BASE + '/';
}
} catch (e) {
setError(e.message || 'Login failed');
setLoading(false);
}
}, [onSuccess]);
const onUserKey = useCallback((e) => {
if (e.key === 'Enter') passRef.current?.focus();
}, []);
const onPassKey = useCallback((e) => {
if (e.key === 'Enter') doLogin();
}, [doLogin]);
return html`
<div class="login-anim login-anim-a3">
<div class="login-form-field">
<label for="loginUsername">Username</label>
<input type="text" id="loginUsername" ref=${userRef}
placeholder="Enter username" autocomplete="username"
autofocus onKeyDown=${onUserKey} />
</div>
<div class="login-form-field">
<label for="loginPassword">Password</label>
<input type="password" id="loginPassword" ref=${passRef}
placeholder="Enter password" autocomplete="current-password"
onKeyDown=${onPassKey} />
</div>
${error && html`<p class="login-auth-error">${error}</p>`}
<button class="login-btn" disabled=${loading} onClick=${doLogin}>
${loading ? 'Logging in\u2026' : 'Log In'}
</button>
</div>
`;
}

View File

@@ -0,0 +1,102 @@
/**
* RegisterForm — registration flow using sw.api.auth.register()
*
* Props:
* onPending — called when account needs admin approval
* onSuccess — called after auto-login on successful registration
*/
const { html } = window;
const { useState, useRef, useCallback } = hooks;
export function RegisterForm({ onPending, onSuccess }) {
const [error, setError] = useState('');
const [errorCls, setErrorCls] = useState('');
const [loading, setLoading] = useState(false);
const userRef = useRef(null);
const emailRef = useRef(null);
const passRef = useRef(null);
const confirmRef = useRef(null);
const BASE = window.__BASE__ || '';
const doRegister = useCallback(async () => {
const username = userRef.current?.value?.trim();
const email = emailRef.current?.value?.trim();
const password = passRef.current?.value;
const confirm = confirmRef.current?.value;
if (!username || !password) {
setError('Username and password are required');
setErrorCls('');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters');
setErrorCls('');
return;
}
if (password !== confirm) {
setError('Passwords do not match');
setErrorCls('');
return;
}
setError('');
setLoading(true);
try {
const data = await sw.api.auth.register(username, email, password);
if (data.pending) {
setError('Account created \u2014 pending admin approval.');
setErrorCls('success');
if (onPending) setTimeout(onPending, 3000);
} else {
// Auto-login: store tokens via auth module
sw.auth._setAuth(data);
if (onSuccess) {
onSuccess();
} else {
window.location.href = BASE + '/';
}
}
} catch (e) {
setError(e.message || 'Registration failed');
setErrorCls('');
} finally {
setLoading(false);
}
}, [onPending, onSuccess]);
const onConfirmKey = useCallback((e) => {
if (e.key === 'Enter') doRegister();
}, [doRegister]);
return html`
<div>
<div class="login-form-field">
<label for="regUsername">Username</label>
<input type="text" id="regUsername" ref=${userRef}
placeholder="Choose a username" autocomplete="username" />
</div>
<div class="login-form-field">
<label for="regEmail">Email</label>
<input type="email" id="regEmail" ref=${emailRef}
placeholder="you@example.com" autocomplete="email" />
</div>
<div class="login-form-field">
<label for="regPassword">Password</label>
<input type="password" id="regPassword" ref=${passRef}
placeholder="Min 8 characters" autocomplete="new-password" />
</div>
<div class="login-form-field">
<label for="regConfirm">Confirm Password</label>
<input type="password" id="regConfirm" ref=${confirmRef}
placeholder="Re-enter password" autocomplete="new-password"
onKeyDown=${onConfirmKey} />
</div>
${error && html`<p class="login-auth-error ${errorCls}">${error}</p>`}
<button class="login-btn" disabled=${loading} onClick=${doRegister}>
${loading ? 'Creating account\u2026' : 'Create Account'}
</button>
</div>
`;
}

View File

@@ -0,0 +1,41 @@
/**
* SSOPanel — OIDC or mTLS auth panels
*
* Props:
* mode — 'oidc' | 'mtls'
* base — base path
*/
const { html } = window;
export function SSOPanel({ mode, base = '' }) {
if (mode === 'oidc') {
return html`
<div class="login-anim login-anim-a2">
<p class="login-sso-desc">
This instance uses Single Sign-On. Click below to
authenticate with your identity provider.
</p>
<a class="login-btn login-btn--link"
href="${base}/api/v1/auth/oidc/login">
Sign in with SSO
</a>
</div>
`;
}
if (mode === 'mtls') {
return html`
<div class="login-anim login-anim-a2">
<p class="login-sso-desc">
This instance uses client certificate authentication.
Ensure your browser has a valid certificate installed.
</p>
<a class="login-btn login-btn--link" href="${base}/">
Continue with Certificate
</a>
</div>
`;
}
return null;
}

View File

@@ -0,0 +1,83 @@
/**
* AppearanceSection — theme, UI scale, message font size
*/
const { html } = window;
const { useState, useCallback } = hooks;
const THEMES = [
{ key: 'light', icon: '\u2600', label: 'Light' },
{ key: 'dark', icon: '\u263E', label: 'Dark' },
{ key: 'system', icon: '\u2395', label: 'System' },
];
function getPrefs() {
try { return JSON.parse(localStorage.getItem('cs-appearance') || '{}'); }
catch { return {}; }
}
export function AppearanceSection() {
const prefs = getPrefs();
const [theme, setTheme] = useState(sw.theme?.current || 'system');
const [scale, setScale] = useState(prefs.scale || 100);
const [msgFont, setMsgFont] = useState(prefs.msgFont || 14);
const applyTheme = useCallback((mode) => {
setTheme(mode);
sw.theme.set(mode);
sw.emit('theme.changed', { mode, resolved: sw.theme.current });
}, []);
const save = useCallback(() => {
const p = getPrefs();
p.scale = scale;
p.msgFont = msgFont;
localStorage.setItem('cs-appearance', JSON.stringify(p));
// Apply zoom + font size
document.documentElement.style.zoom = scale !== 100 ? (scale / 100) : '';
document.documentElement.style.setProperty('--msg-font-size', msgFont + 'px');
sw.emit('toast', { message: 'Appearance saved', variant: 'success' });
}, [scale, msgFont]);
return html`
<div class="settings-section">
<h3>Theme</h3>
<div class="toggle-group" style="display:flex;gap:0;">
${THEMES.map(t => html`
<button class="toggle-btn ${theme === t.key ? 'active' : ''}"
onClick=${() => applyTheme(t.key)}>
${t.icon} ${t.label}
</button>
`)}
</div>
</div>
<div class="settings-section">
<h3>UI Scale</h3>
<div class="form-group">
<div style="display:flex;align-items:center;gap:8px;">
<input type="range" min="80" max="175" step="5"
value=${scale}
onInput=${e => setScale(parseInt(e.target.value))}
style="flex:1;" />
<span style="font-size:12px;color:var(--text-2);font-family:var(--mono);min-width:32px;">
${scale}%
</span>
</div>
</div>
<h3 style="margin-top:16px;">Message Font Size</h3>
<div class="form-group">
<div style="display:flex;align-items:center;gap:8px;">
<input type="range" min="12" max="20" step="1"
value=${msgFont}
onInput=${e => setMsgFont(parseInt(e.target.value))}
style="flex:1;" />
<span style="font-size:12px;color:var(--text-2);font-family:var(--mono);min-width:32px;">
${msgFont}px
</span>
</div>
</div>
</div>
<button class="btn-md btn-primary" onClick=${save}>Save</button>
`;
}

View File

@@ -0,0 +1,32 @@
/**
* BridgeSection — thin Preact wrapper for deferred settings sections
*
* Renders a container <div> with the expected DOM IDs, then calls
* the old JS loader function so legacy code can populate it.
*
* Props:
* id — mount div ID (e.g. 'settingsTasksMount')
* loader — function to call after mount (e.g. () => _loadSettingsTasks?.())
*/
const { html } = window;
const { useEffect, useRef } = hooks;
export function BridgeSection({ id, loader }) {
const ref = useRef(null);
useEffect(() => {
if (loader) {
// Defer to next tick so the DOM node is committed
requestAnimationFrame(() => {
try { loader(); }
catch (e) { console.warn('[settings/bridge]', id, e.message); }
});
}
}, [id, loader]);
return html`
<div ref=${ref} id=${id}>
<div class="settings-placeholder">Loading\u2026</div>
</div>
`;
}

View File

@@ -0,0 +1,130 @@
/**
* GeneralSection — chat defaults: model, system prompt, max tokens, temperature, thinking
*/
const { html } = window;
const { useState, useEffect, useCallback, useRef } = hooks;
export function GeneralSection() {
const [models, setModels] = useState([]);
const [settings, setSettings] = useState({});
const [saving, setSaving] = useState(false);
const [maxHint, setMaxHint] = useState('');
const tempRef = useRef(null);
// Load settings + models
useEffect(() => {
(async () => {
try {
const [s, m] = await Promise.all([
sw.api.profile.settings(),
sw.api.models.enabled(),
]);
const sObj = s || {};
setSettings({
model: sObj.model || '',
system_prompt: sObj.system_prompt || '',
max_tokens: sObj.max_tokens || 0,
temperature: sObj.temperature ?? 0.7,
show_thinking: sObj.show_thinking || false,
});
const modelList = (m?.data || m || []).filter(x => !x.hidden);
setModels(modelList);
// Set initial max hint
const active = modelList.find(x => x.id === sObj.model);
if (active?.capabilities?.max_output_tokens > 0) {
setMaxHint(`(model max: ${(active.capabilities.max_output_tokens / 1000).toFixed(0)}K)`);
}
} catch (e) {
console.warn('[settings/general] Load failed:', e.message);
}
})();
}, []);
const onModelChange = useCallback((e) => {
const id = e.target.value;
setSettings(s => ({ ...s, model: id }));
const m = models.find(x => x.id === id);
const cap = m?.capabilities || {};
setMaxHint(cap.max_output_tokens > 0
? `(model max: ${(cap.max_output_tokens / 1000).toFixed(0)}K)` : '');
}, [models]);
const onTempInput = useCallback((e) => {
setSettings(s => ({ ...s, temperature: parseFloat(e.target.value) }));
}, []);
const save = useCallback(async () => {
setSaving(true);
try {
await sw.api.profile.updateSettings({
model: settings.model,
system_prompt: settings.system_prompt,
max_tokens: settings.max_tokens,
temperature: settings.temperature,
show_thinking: settings.show_thinking,
});
sw.emit('toast', { message: 'Settings saved', variant: 'success' });
} catch (e) {
sw.emit('toast', { message: e.message, variant: 'error' });
} finally {
setSaving(false);
}
}, [settings]);
return html`
<div class="settings-section">
<h3>Chat Defaults</h3>
<div class="form-group">
<label>Default Model</label>
<select value=${settings.model} onChange=${onModelChange}>
${models.map(m => html`
<option value=${m.id}>
${m.name || m.id}${m.provider ? ` (${m.provider})` : ''}
</option>
`)}
</select>
</div>
<div class="form-group">
<label>System Prompt</label>
<textarea rows="3" placeholder="Optional system prompt\u2026"
style="max-width:100%;"
value=${settings.system_prompt}
onInput=${e => setSettings(s => ({ ...s, system_prompt: e.target.value }))} />
</div>
<div style="display:flex;gap:16px;">
<div class="form-group" style="flex:1;">
<label>Max Tokens${' '}
<span style="font-weight:400;color:var(--text-3);">${maxHint}</span>
</label>
<input type="number" placeholder="default"
value=${settings.max_tokens || ''}
onInput=${e => setSettings(s => ({ ...s, max_tokens: parseInt(e.target.value) || 0 }))} />
</div>
<div class="form-group" style="flex:1;">
<label>Temperature</label>
<div style="display:flex;align-items:center;gap:8px;">
<input type="range" min="0" max="2" step="0.1"
ref=${tempRef}
value=${settings.temperature}
onInput=${onTempInput}
style="flex:1;" />
<span style="font-size:12px;color:var(--text-2);font-family:var(--mono);">
${settings.temperature}
</span>
</div>
</div>
</div>
<label style="display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text-2);cursor:pointer;margin-top:4px;">
<input type="checkbox"
checked=${settings.show_thinking}
onChange=${e => setSettings(s => ({ ...s, show_thinking: e.target.checked }))} />
Show thinking blocks
</label>
</div>
<button class="btn-md btn-primary" style="margin-top:12px;"
disabled=${saving} onClick=${save}>
${saving ? 'Saving\u2026' : 'Save'}
</button>
`;
}

View File

@@ -0,0 +1,223 @@
/**
* SettingsSurface — root settings surface
*
* Reads globals:
* __SECTION__ — active section name (string)
* __BASE__ — base path
* __PAGE_DATA__ — { BYOKEnabled, ... } from Go template
*
* Layout: topbar + left nav + content area (same CSS classes as before).
* Preact sections are loaded lazily. Bridge sections delegate to old JS.
*/
const { html } = window;
const { useState, useEffect, useCallback, useMemo } = hooks;
const { render } = preact;
import { BridgeSection } from './bridge-section.js';
import { ToastContainer } from '../../primitives/toast.js';
import { DialogStack } from '../../shell/dialog-stack.js';
// ── Lazy section imports ────────────────────
// Each returns a Promise<{ default: Component }> or { SectionName: Component }
const sectionModules = {
general: () => import('./general.js'),
appearance: () => import('./appearance.js'),
profile: () => import('./profile.js'),
teams: () => import('./teams.js'),
models: () => import('./models.js'),
providers: () => import('./providers.js'),
personas: () => import('./personas.js'),
roles: () => import('./roles.js'),
usage: () => import('./usage.js'),
};
// ── Bridge section config (deferred to old JS) ──
const bridgeSections = {
workflows: { id: 'settingsDynamic', loader: () => typeof loadTeamWorkflows === 'function' && loadTeamWorkflows() },
tasks: { id: 'settingsTasksMount', loader: () => typeof _loadSettingsTasks === 'function' && _loadSettingsTasks() },
gitkeys: { id: 'settingsDynamic', loader: () => typeof _loadSettingsGitKeys === 'function' && _loadSettingsGitKeys() },
data: { id: 'dpMount', loader: () => typeof loadDataPrivacy === 'function' && loadDataPrivacy() },
knowledge: { id: 'settingsDynamic', loader: () => typeof KnowledgeUI !== 'undefined' && KnowledgeUI.openManagePanel?.() },
memory: { id: 'settingsDynamic', loader: () => typeof MemoryUI !== 'undefined' && MemoryUI.openSettingsPanel?.() },
notifications: { id: 'settingsDynamic', loader: () => typeof NotifPrefs !== 'undefined' && NotifPrefs.load?.() },
};
// ── Nav structure ───────────────────────────
const NAV_ITEMS = [
{ key: 'general', label: 'General' },
{ key: 'appearance', label: 'Appearance' },
{ key: 'models', label: 'Models' },
{ key: 'personas', label: 'Personas', gate: 'personas' },
{ key: 'profile', label: 'Profile' },
{ key: 'teams', label: 'Teams' },
{ key: 'workflows', label: 'Workflows' },
{ key: 'tasks', label: 'Tasks' },
{ key: 'gitkeys', label: 'Git Keys' },
{ key: 'data', label: 'Data & Privacy' },
];
const BYOK_ITEMS = [
{ key: 'providers', label: 'My Providers' },
{ key: 'roles', label: 'Model Roles' },
{ key: 'usage', label: 'My Usage' },
];
// ── Section title map ───────────────────────
const SECTION_TITLES = {
general: 'General', appearance: 'Appearance', models: 'Models',
personas: 'Personas', profile: 'Profile', teams: 'Teams',
workflows: 'Workflows', tasks: 'Tasks', gitkeys: 'Git Keys',
data: 'Data & Privacy', providers: 'My Providers', roles: 'Model Roles',
usage: 'My Usage', knowledge: 'Knowledge Bases', memory: 'Memory',
notifications: 'Notifications',
};
function SettingsSurface() {
const BASE = window.__BASE__ || '';
const section = window.__SECTION__ || 'general';
const pageData = window.__PAGE_DATA__ || {};
const [byokEnabled, setByokEnabled] = useState(!!pageData.BYOKEnabled);
const [personasEnabled, setPersonasEnabled] = useState(!!pageData.UserPersonasEnabled);
const [SectionComponent, setSectionComponent] = useState(null);
// Fetch policies if not provided by template
useEffect(() => {
if (!pageData.BYOKEnabled || !pageData.UserPersonasEnabled) {
sw.api.admin?.settings?.public?.().then(data => {
const policies = data?.policies || {};
if (policies.allow_user_byok === 'true') setByokEnabled(true);
if (policies.allow_user_personas === 'true') setPersonasEnabled(true);
}).catch(() => {});
}
}, []);
// Load the section component
useEffect(() => {
setSectionComponent(null);
if (sectionModules[section]) {
sectionModules[section]().then(mod => {
// Module exports the component as the first named export
const Comp = mod.default || Object.values(mod)[0];
setSectionComponent(() => Comp);
}).catch(e => {
console.error('[settings] Failed to load section:', section, e);
});
}
// Bridge sections are handled inline
}, [section]);
const onNavClick = useCallback((e) => {
e.preventDefault();
const href = e.currentTarget.getAttribute('href');
if (href) location.replace(href);
}, []);
const backClick = useCallback((e) => {
e.preventDefault();
const RETURN_KEY = 'sb_settings_return';
const returnURL = sessionStorage.getItem(RETURN_KEY);
sessionStorage.removeItem(RETURN_KEY);
location.href = returnURL || BASE + '/';
}, []);
// Stash return URL on first load
useEffect(() => {
const RETURN_KEY = 'sb_settings_return';
if (!sessionStorage.getItem(RETURN_KEY)) {
const ref = document.referrer;
if (ref && ref.startsWith(location.origin) && !ref.includes('/settings')) {
sessionStorage.setItem(RETURN_KEY, ref);
} else {
sessionStorage.setItem(RETURN_KEY, location.origin + BASE + '/');
}
}
}, []);
const title = SECTION_TITLES[section] || 'Settings';
return html`
<div class="surface-settings" style="flex-direction:column;">
${/* Top Bar */''}
<div class="settings-topbar">
<a href="${BASE}/" class="settings-topbar-back" onClick=${backClick}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>
Back
</a>
<div class="settings-topbar-sep"></div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
style="color:var(--text-2)">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
</svg>
<span class="settings-topbar-title">Settings</span>
</div>
<div style="display:flex;flex:1;min-height:0;">
${/* Left Nav */''}
<div class="settings-nav">
${NAV_ITEMS
.filter(item => !item.gate || (item.gate === 'personas' && personasEnabled))
.map(item => html`
<a href="${BASE}/settings/${item.key}"
class="settings-nav-link ${section === item.key ? 'active' : ''}"
onClick=${onNavClick}>
${item.label}
</a>
`)}
${byokEnabled && html`
<div>
<div class="settings-nav-sep"></div>
${BYOK_ITEMS.map(item => html`
<a href="${BASE}/settings/${item.key}"
class="settings-nav-link ${section === item.key ? 'active' : ''}"
onClick=${onNavClick}>
${item.label}
</a>
`)}
</div>
<div class="settings-nav-footer">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span style="font-size:11px;font-weight:600;color:var(--success);">BYOK Enabled</span>
</div>
<p style="font-size:10px;color:var(--text-3);margin:0;line-height:1.5;">
Admin has enabled Bring Your Own Key
</p>
</div>
`}
</div>
${/* Content */''}
<div class="settings-content">
<h2>${title}</h2>
${bridgeSections[section]
? html`<${BridgeSection} id=${bridgeSections[section].id}
loader=${bridgeSections[section].loader} />`
: SectionComponent
? html`<${SectionComponent} />`
: html`<div class="settings-placeholder">Loading\u2026</div>`
}
</div>
</div>
</div>
<${ToastContainer} />
<${DialogStack} />
`;
}
// ── Mount ────────────────────────────────────
const mount = document.getElementById('settings-mount');
if (mount) {
render(html`<${SettingsSurface} />`, mount);
console.log('[settings] Surface mounted');
}
export { SettingsSurface };

View File

@@ -0,0 +1,110 @@
/**
* ModelsSection — user model list with visibility toggle
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
function esc(s) { return s == null ? '' : String(s); }
export function ModelsSection() {
const [models, setModels] = useState(null);
const [prefs, setPrefs] = useState({});
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState('');
const [provFilter, setProvFilter] = useState('');
useEffect(() => {
(async () => {
try {
const [m, p] = await Promise.all([
sw.api.models.enabled(),
sw.api.models.preferences(),
]);
setModels(m?.data || m || []);
// Build prefs map: model_id → { hidden }
const map = {};
(p?.data || p || []).forEach(x => { map[x.model_id] = x; });
setPrefs(map);
} catch (e) {
console.warn('[settings/models]', e.message);
setModels([]);
}
})();
}, []);
const toggleHidden = useCallback(async (model) => {
const current = prefs[model.id]?.hidden || false;
try {
await sw.api.models.setPref(model.id, model.provider_config_id, !current);
setPrefs(p => ({ ...p, [model.id]: { ...p[model.id], hidden: !current } }));
} catch (e) {
sw.emit('toast', { message: e.message, variant: 'error' });
}
}, [prefs]);
if (models === null) {
return html`<div class="settings-placeholder">Loading models\u2026</div>`;
}
// Derive filter options
const types = [...new Set(models.map(m => m.model_type).filter(Boolean))];
const providers = [...new Set(models.map(m => m.provider_name || m.provider).filter(Boolean))];
const filtered = models.filter(m => {
const name = (m.display_name || m.model_id || '').toLowerCase();
if (search && !name.includes(search.toLowerCase())) return false;
if (typeFilter && m.model_type !== typeFilter) return false;
if (provFilter && (m.provider_name || m.provider) !== provFilter) return false;
return true;
});
return html`
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap;">
<input type="text" placeholder="Search models\u2026"
style="flex:1;min-width:140px;"
value=${search} onInput=${e => setSearch(e.target.value)} />
${types.length > 1 && html`
<select value=${typeFilter} onChange=${e => setTypeFilter(e.target.value)}>
<option value="">All types</option>
${types.map(t => html`<option value=${t}>${t}</option>`)}
</select>
`}
${providers.length > 1 && html`
<select value=${provFilter} onChange=${e => setProvFilter(e.target.value)}>
<option value="">All providers</option>
${providers.map(p => html`<option value=${p}>${p}</option>`)}
</select>
`}
</div>
<table class="admin-table">
<thead>
<tr><th>Model</th><th>Type</th><th>Provider</th><th style="width:60px;">Visible</th></tr>
</thead>
<tbody>
${filtered.map(m => {
const hidden = prefs[m.id]?.hidden || false;
return html`
<tr style=${hidden ? 'opacity:0.5' : ''}>
<td style="font-weight:500;font-size:13px;">
${esc(m.display_name || m.model_id)}
</td>
<td style="font-size:12px;color:var(--text-2);">
${esc(m.model_type)}
</td>
<td style="font-size:12px;color:var(--text-2);">
${esc(m.provider_name || m.provider)}
</td>
<td>
<input type="checkbox" checked=${!hidden}
onChange=${() => toggleHidden(m)} />
</td>
</tr>
`;
})}
</tbody>
</table>
<div style="font-size:12px;color:var(--text-3);margin-top:8px;">
${filtered.length} model${filtered.length !== 1 ? 's' : ''}
</div>
`;
}

View File

@@ -0,0 +1,154 @@
/**
* PersonasSection — user persona list + create/edit
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
function esc(s) { return s == null ? '' : String(s); }
export function PersonasSection() {
const [personas, setPersonas] = useState(null);
const [showForm, setShowForm] = useState(false);
const [editId, setEditId] = useState(null);
const [form, setForm] = useState({ name: '', description: '', system_prompt: '' });
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
const resp = await sw.api.personas.list();
setPersonas(resp?.data || resp || []);
} catch (e) {
setPersonas([]);
}
}, []);
useEffect(() => { load(); }, [load]);
const openAdd = useCallback(() => {
setEditId(null);
setForm({ name: '', description: '', system_prompt: '' });
setShowForm(true);
}, []);
const openEdit = useCallback((p) => {
setEditId(p.id);
setForm({ name: p.name || '', description: p.description || '', system_prompt: p.system_prompt || '' });
setShowForm(true);
}, []);
const cancel = useCallback(() => {
setShowForm(false);
setEditId(null);
}, []);
const save = useCallback(async () => {
if (!form.name.trim()) {
sw.emit('toast', { message: 'Name is required', variant: 'error' });
return;
}
setSaving(true);
try {
if (editId) {
await sw.api.personas.update(editId, form);
sw.emit('toast', { message: 'Persona updated', variant: 'success' });
} else {
await sw.api.personas.create(form);
sw.emit('toast', { message: 'Persona created', variant: 'success' });
}
setShowForm(false);
setEditId(null);
load();
} catch (e) {
sw.emit('toast', { message: e.message, variant: 'error' });
} finally {
setSaving(false);
}
}, [form, editId, load]);
const del = useCallback(async (p) => {
const ok = await sw.confirm(`Delete persona "${p.name}"?`);
if (!ok) return;
try {
await sw.api.personas.del(p.id);
sw.emit('toast', { message: 'Persona deleted', variant: 'success' });
load();
} catch (e) {
sw.emit('toast', { message: e.message, variant: 'error' });
}
}, [load]);
const setField = useCallback((key, val) => {
setForm(f => ({ ...f, [key]: val }));
}, []);
if (personas === null) {
return html`<div class="settings-placeholder">Loading personas\u2026</div>`;
}
return html`
<div style="margin-bottom:12px;">
<button class="btn-md btn-primary" onClick=${openAdd}>+ New Persona</button>
</div>
${showForm && html`
<div class="settings-section" style="margin-bottom:16px;">
<h3>${editId ? 'Edit Persona' : 'New Persona'}</h3>
<div class="form-group">
<label>Name</label>
<input type="text" value=${form.name}
onInput=${e => setField('name', e.target.value)} />
</div>
<div class="form-group">
<label>Description</label>
<input type="text" value=${form.description}
onInput=${e => setField('description', e.target.value)} />
</div>
<div class="form-group">
<label>System Prompt</label>
<textarea rows="4" value=${form.system_prompt}
onInput=${e => setField('system_prompt', e.target.value)}
style="max-width:100%;" />
</div>
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="btn-md btn-primary" disabled=${saving}
onClick=${save}>
${saving ? 'Saving\u2026' : editId ? 'Update' : 'Create'}
</button>
<button class="btn-md btn-secondary" onClick=${cancel}>Cancel</button>
</div>
</div>
`}
${!personas.length && !showForm && html`
<div class="empty-hint">No personas configured.</div>
`}
${personas.length > 0 && html`
<div>
${personas.map(p => html`
<div class="settings-section" style="margin-bottom:12px;">
<div style="display:flex;align-items:center;justify-content:space-between;">
<div>
<strong style="font-size:14px;">${esc(p.name)}</strong>
${p.description && html`
<div style="font-size:12px;color:var(--text-2);margin-top:2px;">
${esc(p.description)}
</div>
`}
</div>
<div style="display:flex;gap:4px;">
<button class="icon-btn" title="Edit" onClick=${() => openEdit(p)}>
\u{270F}\u{FE0F}
</button>
<button class="icon-btn icon-btn-danger" title="Delete"
onClick=${() => del(p)}>
\u{1F5D1}
</button>
</div>
</div>
</div>
`)}
</div>
`}
`;
}

View File

@@ -0,0 +1,180 @@
/**
* ProfileSection — display name, email, avatar, password change
*/
const { html } = window;
const { useState, useEffect, useCallback, useRef } = hooks;
export function ProfileSection() {
const [displayName, setDisplayName] = useState('');
const [email, setEmail] = useState('');
const [avatarUrl, setAvatarUrl] = useState(null);
const [savingProfile, setSavingProfile] = useState(false);
// Password state
const [curPw, setCurPw] = useState('');
const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState('');
const [savingPw, setSavingPw] = useState(false);
const fileRef = useRef(null);
// Load profile
useEffect(() => {
(async () => {
try {
const p = await sw.api.profile.get();
setDisplayName(p.display_name || p.username || '');
setEmail(p.email || '');
setAvatarUrl(p.avatar || p.avatar_url || null);
} catch (e) {
// Fall back to auth user
const u = sw.auth.user;
if (u) {
setDisplayName(u.display_name || u.username || '');
setEmail(u.email || '');
setAvatarUrl(u.avatar || null);
}
}
})();
}, []);
const saveProfile = useCallback(async () => {
if (!displayName.trim()) {
sw.emit('toast', { message: 'Display name is required', variant: 'error' });
return;
}
setSavingProfile(true);
try {
await sw.api.profile.update({ display_name: displayName.trim() });
sw.emit('toast', { message: 'Profile saved', variant: 'success' });
} catch (e) {
sw.emit('toast', { message: e.message, variant: 'error' });
} finally {
setSavingProfile(false);
}
}, [displayName]);
const changePassword = useCallback(async () => {
if (!curPw || !newPw) {
sw.emit('toast', { message: 'All password fields are required', variant: 'error' });
return;
}
if (newPw !== confirmPw) {
sw.emit('toast', { message: 'Passwords do not match', variant: 'error' });
return;
}
if (newPw.length < 8) {
sw.emit('toast', { message: 'Password must be at least 8 characters', variant: 'error' });
return;
}
setSavingPw(true);
try {
await sw.api.profile.password(curPw, newPw);
sw.emit('toast', { message: 'Password changed', variant: 'success' });
setCurPw(''); setNewPw(''); setConfirmPw('');
} catch (e) {
sw.emit('toast', { message: e.message, variant: 'error' });
} finally {
setSavingPw(false);
}
}, [curPw, newPw, confirmPw]);
// Avatar upload
const onAvatarFile = useCallback(async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async () => {
try {
await sw.api.profile.avatar(reader.result);
setAvatarUrl(reader.result);
sw.emit('toast', { message: 'Avatar updated', variant: 'success' });
} catch (err) {
sw.emit('toast', { message: err.message, variant: 'error' });
}
};
reader.readAsDataURL(file);
}, []);
const removeAvatar = useCallback(async () => {
try {
await sw.api.profile.deleteAvatar();
setAvatarUrl(null);
sw.emit('toast', { message: 'Avatar removed', variant: 'success' });
} catch (e) {
sw.emit('toast', { message: e.message, variant: 'error' });
}
}, []);
const initial = (displayName || '?')[0].toUpperCase();
return html`
<div class="settings-section">
<h3>Profile</h3>
${/* Avatar */''}
<div style="display:flex;align-items:center;gap:16px;margin-bottom:16px;">
<div class="avatar-preview" style="width:56px;height:56px;border-radius:50%;overflow:hidden;
background:var(--bg-raised);display:flex;align-items:center;justify-content:center;
font-size:24px;font-weight:600;color:var(--text-2);flex-shrink:0;">
${avatarUrl
? html`<img src=${avatarUrl} alt="Avatar"
style="width:100%;height:100%;object-fit:cover;" />`
: initial
}
</div>
<div style="display:flex;gap:8px;">
<button class="btn-sm btn-secondary"
onClick=${() => fileRef.current?.click()}>
Upload
</button>
${avatarUrl && html`
<button class="btn-sm btn-secondary" onClick=${removeAvatar}>
Remove
</button>
`}
<input type="file" ref=${fileRef} accept="image/*"
style="display:none;" onChange=${onAvatarFile} />
</div>
</div>
<div class="form-group">
<label>Display Name</label>
<input type="text" placeholder="Your display name"
value=${displayName}
onInput=${e => setDisplayName(e.target.value)} />
</div>
<div class="form-group">
<label>Email</label>
<input type="email" disabled value=${email} />
</div>
<button class="btn-md btn-primary" disabled=${savingProfile}
onClick=${saveProfile}>
${savingProfile ? 'Saving\u2026' : 'Save Profile'}
</button>
</div>
<div class="settings-section" style="margin-top:20px;">
<h3>Change Password</h3>
<div class="form-group">
<label>Current Password</label>
<input type="password" autocomplete="current-password"
value=${curPw} onInput=${e => setCurPw(e.target.value)} />
</div>
<div class="form-group">
<label>New Password</label>
<input type="password" autocomplete="new-password"
value=${newPw} onInput=${e => setNewPw(e.target.value)} />
</div>
<div class="form-group">
<label>Confirm New Password</label>
<input type="password" autocomplete="new-password"
value=${confirmPw} onInput=${e => setConfirmPw(e.target.value)} />
</div>
<button class="btn-md btn-primary" disabled=${savingPw}
onClick=${changePassword}>
${savingPw ? 'Updating\u2026' : 'Update Password'}
</button>
</div>
`;
}

View File

@@ -0,0 +1,199 @@
/**
* ProvidersSection — BYOK provider CRUD (user-scoped)
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
function esc(s) { return s == null ? '' : String(s); }
export function ProvidersSection() {
const [providers, setProviders] = useState(null);
const [showForm, setShowForm] = useState(false);
const [editId, setEditId] = useState(null);
const [form, setForm] = useState({ name: '', provider: 'openai', endpoint: '', api_key: '', model_default: '' });
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
const resp = await sw.api.providers.list();
setProviders(resp?.configs || resp?.data || resp || []);
} catch (e) {
setProviders([]);
}
}, []);
useEffect(() => { load(); }, [load]);
const openAdd = useCallback(() => {
setEditId(null);
setForm({ name: '', provider: 'openai', endpoint: '', api_key: '', model_default: '' });
setShowForm(true);
}, []);
const openEdit = useCallback(async (prov) => {
try {
const cfg = await sw.api.providers.get(prov.id);
setEditId(cfg.id);
setForm({
name: cfg.name || '',
provider: cfg.provider || 'openai',
endpoint: cfg.endpoint || '',
api_key: '',
model_default: cfg.model_default || '',
});
setShowForm(true);
} catch (e) {
sw.emit('toast', { message: e.message, variant: 'error' });
}
}, []);
const cancel = useCallback(() => {
setShowForm(false);
setEditId(null);
}, []);
const save = useCallback(async () => {
if (!form.name || !form.endpoint) {
sw.emit('toast', { message: 'Name and endpoint are required', variant: 'error' });
return;
}
if (!editId && !form.api_key) {
sw.emit('toast', { message: 'API key is required', variant: 'error' });
return;
}
setSaving(true);
try {
if (editId) {
const patch = { name: form.name, provider: form.provider, endpoint: form.endpoint, model_default: form.model_default };
if (form.api_key) patch.api_key = form.api_key;
await sw.api.providers.update(editId, patch);
sw.emit('toast', { message: 'Provider updated', variant: 'success' });
} else {
const body = { name: form.name, provider: form.provider, endpoint: form.endpoint, api_key: form.api_key, model_default: form.model_default };
const result = await sw.api.providers.create(body);
const count = result?.models_fetched || 0;
sw.emit('toast', {
message: result?.warning || `Provider added \u2014 ${count} model${count !== 1 ? 's' : ''} synced`,
variant: result?.warning ? 'warning' : 'success',
});
}
setShowForm(false);
setEditId(null);
load();
} catch (e) {
sw.emit('toast', { message: e.message, variant: 'error' });
} finally {
setSaving(false);
}
}, [form, editId, load]);
const del = useCallback(async (prov) => {
const ok = await sw.confirm(`Remove provider "${prov.name}"?`);
if (!ok) return;
try {
await sw.api.providers.del(prov.id);
sw.emit('toast', { message: 'Provider removed', variant: 'success' });
load();
} catch (e) {
sw.emit('toast', { message: e.message, variant: 'error' });
}
}, [load]);
const sync = useCallback(async (prov) => {
sw.emit('toast', { message: `Fetching models for ${prov.name}\u2026`, variant: 'info' });
try {
const result = await sw.api.providers.fetchModels(prov.id);
const total = result?.total || 0;
sw.emit('toast', { message: `${prov.name}: ${total} model${total !== 1 ? 's' : ''} synced`, variant: 'success' });
} catch (e) {
sw.emit('toast', { message: e.message, variant: 'error' });
}
}, []);
const setField = useCallback((key, val) => {
setForm(f => ({ ...f, [key]: val }));
}, []);
if (providers === null) {
return html`<div class="settings-placeholder">Loading providers\u2026</div>`;
}
return html`
<div style="margin-bottom:12px;">
<button class="btn-md btn-primary" onClick=${openAdd}>+ Add Provider</button>
</div>
${showForm && html`
<div class="settings-section" style="margin-bottom:16px;">
<h3>${editId ? 'Edit Provider' : 'Add Provider'}</h3>
<div class="form-group">
<label>Name</label>
<input type="text" value=${form.name}
onInput=${e => setField('name', e.target.value)} />
</div>
<div class="form-group">
<label>Provider Type</label>
<select value=${form.provider}
onChange=${e => setField('provider', e.target.value)}>
<option value="openai">OpenAI-compatible</option>
<option value="anthropic">Anthropic</option>
<option value="openrouter">OpenRouter</option>
<option value="venice">Venice AI</option>
</select>
</div>
<div class="form-group">
<label>Endpoint URL</label>
<input type="text" placeholder="https://api.openai.com/v1"
value=${form.endpoint}
onInput=${e => setField('endpoint', e.target.value)} />
</div>
<div class="form-group">
<label>API Key${editId ? ' (leave blank to keep current)' : ''}</label>
<input type="password" placeholder="sk-..."
value=${form.api_key}
onInput=${e => setField('api_key', e.target.value)} />
</div>
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="btn-md btn-primary" disabled=${saving}
onClick=${save}>
${saving ? 'Saving\u2026' : editId ? 'Update' : 'Add'}
</button>
<button class="btn-md btn-secondary" onClick=${cancel}>Cancel</button>
</div>
</div>
`}
${!providers.length && !showForm && html`
<div class="empty-hint">No providers configured.</div>
`}
${providers.length > 0 && html`
<table class="admin-table">
<thead><tr><th>Provider</th><th>Type</th><th>Endpoint</th><th></th></tr></thead>
<tbody>
${providers.map(p => html`
<tr>
<td style="font-weight:500;font-size:13px;">${esc(p.name)}</td>
<td style="font-size:12px;color:var(--text-2);">${esc(p.provider)}</td>
<td style="font-size:12px;color:var(--text-3);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
${esc(p.endpoint)}
</td>
<td class="admin-actions-cell" style="white-space:nowrap;">
<button class="icon-btn" title="Sync models" onClick=${() => sync(p)}>
\u{1F504}
</button>
<button class="icon-btn" title="Edit" onClick=${() => openEdit(p)}>
\u{270F}\u{FE0F}
</button>
<button class="icon-btn icon-btn-danger" title="Delete"
onClick=${() => del(p)}>
\u{1F5D1}
</button>
</td>
</tr>
`)}
</tbody>
</table>
`}
`;
}

View File

@@ -0,0 +1,127 @@
/**
* RolesSection — user model role overrides (requires BYOK providers)
*
* Each "role" (primary, fallback) can be overridden to use a personal
* provider + model instead of the org default.
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
function esc(s) { return s == null ? '' : String(s); }
const ROLE_NAMES = ['primary', 'fallback'];
export function RolesSection() {
const [providers, setProviders] = useState([]);
const [models, setModels] = useState([]);
const [roles, setRoles] = useState({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
(async () => {
try {
const [provResp, modResp, settings] = await Promise.all([
sw.api.providers.list(),
sw.api.models.enabled(),
sw.api.profile.settings(),
]);
const provs = (provResp?.configs || provResp?.data || [])
.filter(c => c.scope === 'personal');
setProviders(provs);
setModels(modResp?.data || modResp || []);
// Extract role overrides from settings
const overrides = settings?.model_roles || {};
setRoles(overrides);
} catch (e) {
console.warn('[settings/roles]', e.message);
} finally {
setLoading(false);
}
})();
}, []);
const setRoleProvider = useCallback((role, provId) => {
setRoles(r => ({
...r,
[role]: { ...(r[role] || {}), provider_config_id: provId, model_id: '' },
}));
}, []);
const setRoleModel = useCallback((role, modelId) => {
setRoles(r => ({
...r,
[role]: { ...(r[role] || {}), model_id: modelId },
}));
}, []);
const save = useCallback(async () => {
setSaving(true);
try {
await sw.api.profile.updateSettings({ model_roles: roles });
sw.emit('toast', { message: 'Roles saved', variant: 'success' });
} catch (e) {
sw.emit('toast', { message: e.message, variant: 'error' });
} finally {
setSaving(false);
}
}, [roles]);
if (loading) {
return html`<div class="settings-placeholder">Loading\u2026</div>`;
}
if (!providers.length) {
return html`
<div class="settings-section">
<p style="color:var(--text-2);font-size:13px;">
Add a personal provider first to configure model role overrides.
</p>
</div>
`;
}
return html`
${ROLE_NAMES.map(role => {
const r = roles[role] || {};
const provId = r.provider_config_id || '';
const modelId = r.model_id || '';
const filteredModels = provId
? models.filter(m => m.provider_config_id === provId)
: [];
return html`
<div class="settings-section" style="margin-bottom:16px;">
<h3 style="text-transform:capitalize;">${role}</h3>
<div style="display:flex;gap:12px;">
<div class="form-group" style="flex:1;">
<label>Provider</label>
<select value=${provId}
onChange=${e => setRoleProvider(role, e.target.value)}>
<option value="">\u2014 use org default \u2014</option>
${providers.map(p => html`
<option value=${p.id}>${esc(p.name)}</option>
`)}
</select>
</div>
<div class="form-group" style="flex:1;">
<label>Model</label>
<select value=${modelId} disabled=${!provId}
onChange=${e => setRoleModel(role, e.target.value)}>
<option value="">\u2014 select model \u2014</option>
${filteredModels.map(m => html`
<option value=${m.model_id || m.id}>
${esc(m.display_name || m.model_id || m.id)}
</option>
`)}
</select>
</div>
</div>
</div>
`;
})}
<button class="btn-md btn-primary" disabled=${saving} onClick=${save}>
${saving ? 'Saving\u2026' : 'Save Roles'}
</button>
`;
}

View File

@@ -0,0 +1,47 @@
/**
* TeamsSection — read-only team membership list
*/
const { html } = window;
const { useState, useEffect } = hooks;
function esc(s) { return s == null ? '' : String(s); }
export function TeamsSection() {
const [teams, setTeams] = useState(null);
useEffect(() => {
sw.api.teams.mine().then(resp => {
setTeams(resp?.data || resp || []);
}).catch(() => setTeams([]));
}, []);
if (teams === null) {
return html`<div class="settings-placeholder">Loading teams\u2026</div>`;
}
if (!teams.length) {
return html`<div class="empty-hint">You are not a member of any teams.</div>`;
}
return html`
${teams.map(t => html`
<div class="settings-section" style="margin-bottom:16px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<h3 style="margin:0">${esc(t.name)}</h3>
<span class="badge-${t.my_role === 'admin' ? 'success' : 'muted'}"
style="font-size:11px;">
${t.my_role}
</span>
</div>
${t.description && html`
<p style="color:var(--text-2);font-size:13px;margin:0 0 8px;">
${esc(t.description)}
</p>
`}
<div style="font-size:12px;color:var(--text-3);">
${t.member_count} member${t.member_count !== 1 ? 's' : ''}
</div>
</div>
`)}
`;
}

View File

@@ -0,0 +1,65 @@
/**
* UsageSection — personal usage statistics
*/
const { html } = window;
const { useState, useEffect } = hooks;
function esc(s) { return s == null ? '' : String(s); }
function fmt(n) { return n == null ? '0' : n.toLocaleString(); }
export function UsageSection() {
const [usage, setUsage] = useState(null);
useEffect(() => {
// Use the generic API escape hatch since there's no dedicated namespace
sw.api.get('/api/v1/profile/usage').then(data => {
setUsage(data);
}).catch(() => setUsage({}));
}, []);
if (usage === null) {
return html`<div class="settings-placeholder">Loading usage\u2026</div>`;
}
const totals = usage.totals || usage;
const byModel = usage.by_model || [];
return html`
<div class="settings-section">
<h3>Totals</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;">
${[
['Requests', totals.request_count],
['Input Tokens', totals.input_tokens],
['Output Tokens', totals.output_tokens],
['Total Tokens', totals.total_tokens],
].map(([label, val]) => html`
<div style="background:var(--bg-raised);border-radius:8px;padding:14px;">
<div style="font-size:11px;color:var(--text-3);margin-bottom:4px;">${label}</div>
<div style="font-size:18px;font-weight:600;">${fmt(val)}</div>
</div>
`)}
</div>
</div>
${byModel.length > 0 && html`
<div class="settings-section" style="margin-top:16px;">
<h3>By Model</h3>
<table class="admin-table">
<thead>
<tr><th>Model</th><th>Requests</th><th>Tokens</th></tr>
</thead>
<tbody>
${byModel.map(m => html`
<tr>
<td style="font-size:13px;">${esc(m.model_id || m.model)}</td>
<td style="font-size:12px;">${fmt(m.request_count)}</td>
<td style="font-size:12px;">${fmt(m.total_tokens)}</td>
</tr>
`)}
</tbody>
</table>
</div>
`}
`;
}