diff --git a/CHANGELOG.md b/CHANGELOG.md index 9262e58..5758172 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,106 @@ # 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 ``, since surfaces render standalone). +- **`sw-login.css`** — login-specific styles extracted from inline + ` -
- - {{if .Banner.Visible}} - - {{end}} - - - - {{if .Banner.Visible}} - - {{end}} - -
+
- - - diff --git a/server/pages/templates/surfaces/settings.html b/server/pages/templates/surfaces/settings.html index 8245f31..e2d49e2 100644 --- a/server/pages/templates/surfaces/settings.html +++ b/server/pages/templates/surfaces/settings.html @@ -1,181 +1,27 @@ {{/* - Settings surface — matches switchboard-prototype-settings.jsx. - Full-page layout: topbar + left nav + content area. - JS (settings-handlers.js, ui-settings.js) populates dynamic sections. + Settings surface — v0.37.5: Preact component tree. + Replaces server-rendered DOM with a mount point. + Bridge sections (tasks, gitkeys, workflows, data) still load old JS. */}} {{define "surface-settings"}} -
- - {{/* Top Bar */}} -
- - - Back - -
- - Settings -
- -
- {{/* Left Nav */}} -
- {{$section := .Section}} - {{$base := .BasePath}} - - General - Appearance - Models - Personas - Profile - Teams - Workflows - Tasks - Git Keys - Data & Privacy - - {{/* BYOK-gated nav items */}} - - - {{/* BYOK indicator */}} - -
- - {{/* Content */}} -
-
-

{{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 & Privacy{{else}}Settings{{end}}

- - {{if eq .Section "general"}} -
-

Chat Defaults

-
- - -
-
- - -
-
-
- - -
-
- -
- - 0.7 -
-
-
- -
- {{else if eq .Section "appearance"}} -
-

Theme

-
- - - -
-
-
-

UI Scale

-
-
- - 100% -
-
-

Message Font Size

-
-
- - 14px -
-
-
- - {{else if eq .Section "profile"}} -
-

Profile

-
-
- -
-
-

Change Password

-
-
-
- -
- {{else if eq .Section "providers"}} - -
- -
- -
Loading providers…
- {{else if eq .Section "personas"}} -
- -
- -
Loading personas…
- {{else if eq .Section "models"}} -
Loading models…
- {{else if eq .Section "roles"}} - -
- {{else if eq .Section "usage"}} -
- {{else if eq .Section "teams"}} -
Loading teams…
- {{else if eq .Section "workflows"}} -
Loading workflows…
- {{else if eq .Section "tasks"}} -
Loading tasks…
- {{else if eq .Section "data"}} -
Loading…
- {{else}}
Loading…
- {{end}} -
-
-
+
+ {{/* Preact renders into this div. Show a loading state until it mounts. */}} +
Loading settings…
{{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}} {{/* Scripts */}} {{define "scripts-settings"}} - - +{{/* Inject section name for Preact routing */}} + + +{{/* Bridge section JS — deferred sections still use old imperative code */}} @@ -185,103 +31,22 @@ - {{end}} diff --git a/server/version.go b/server/version.go index cc0b0e3..3632383 100644 --- a/server/version.go +++ b/server/version.go @@ -1,3 +1,3 @@ package main -const Version = "0.36.0" +const Version = "0.37.5" diff --git a/src/css/sw-login.css b/src/css/sw-login.css new file mode 100644 index 0000000..95bd013 --- /dev/null +++ b/src/css/sw-login.css @@ -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; } +} diff --git a/src/dev.html b/src/dev.html index f01c710..b302e96 100644 --- a/src/dev.html +++ b/src/dev.html @@ -307,6 +307,74 @@ `; } + // ── Surfaces Demo Page ──────────────────── + function SurfacesPage() { + return html` + + `; + } + function PrimitivesPage() { const [dialogOpen, setDialogOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); @@ -579,6 +647,7 @@ +
<${ShellControls} ...${{ bannerOn, setBannerOn, bannerVariant, setBannerVariant, bannerText, setBannerText, @@ -590,6 +659,7 @@ ${page === 'primitives' && html`<${PrimitivesPage} />`} ${page === 'shell' && html`<${ShellPage} />`} ${page === 'sdk' && html`<${SDKPage} />`} + ${page === 'surfaces' && html`<${SurfacesPage} />`}
diff --git a/src/js/__tests__/policy-gating.test.js b/src/js/__tests__/policy-gating.test.js index 019ecf9..72427a7 100644 --- a/src/js/__tests__/policy-gating.test.js +++ b/src/js/__tests__/policy-gating.test.js @@ -275,20 +275,16 @@ describe('SPA bridge — admin settings handler references policy keys', () => { // ── HTML element existence checks ──────────── // v0.22.5: Elements now live in server templates, not index.html. -// Settings surface dynamic sections (providers, personas) have scaffold -// containers rendered by Go templates that JS then populates. +// v0.37.5: Settings surface elements moved to Preact components +// (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', () => { const templateSrc = readAllTemplates(); const requiredElements = [ - // Settings surface — provider section scaffold - '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 + // Admin settings — policy toggles (still in Go templates) 'settUserBYOK', // Admin toggle for BYOK (was adminUserProvidersToggle) '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 ──────── // adminMemberUser is created by ui-admin.js loadMemberUserDropdown() // which runs inside the SPA chat surface. Verify the JS function exists. diff --git a/src/js/sw/sdk/auth.js b/src/js/sw/sdk/auth.js index 978aecf..3f5c174 100644 --- a/src/js/sw/sdk/auth.js +++ b/src/js/sw/sdk/auth.js @@ -237,6 +237,7 @@ export function createAuth() { window.location.href = base + '/login'; }, + _setAuth(data) { _setAuth(data); }, _setRestClient(client) { _restClient = client; }, _setEmit(fn) { _emit = fn; }, }; diff --git a/src/js/sw/sdk/index.js b/src/js/sw/sdk/index.js index 9bda96c..ee1a081 100644 --- a/src/js/sw/sdk/index.js +++ b/src/js/sw/sdk/index.js @@ -93,10 +93,21 @@ export async function boot() { sw.pipe = pipe; sw.theme = theme; - // Shell helpers — imperative confirm/prompt backed by DialogStack + // Shell helpers — imperative confirm/prompt backed by primitives sw.confirm = confirm; 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) sw.userMenu = function (container, opts = {}) { import('../shell/user-menu.js').then(({ UserMenu }) => { @@ -106,7 +117,7 @@ export async function boot() { }; // Marker for idempotency - sw._sdk = '0.37.4'; + sw._sdk = '0.37.5'; // 8. Expose globally window.sw = sw; @@ -128,7 +139,7 @@ export async function boot() { // 10. Signal ready events.emit('sdk.ready', {}, { localOnly: true }); 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; } diff --git a/src/js/sw/surfaces/login/hero.js b/src/js/sw/surfaces/login/hero.js new file mode 100644 index 0000000..175d74a --- /dev/null +++ b/src/js/sw/surfaces/login/hero.js @@ -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` +
+ + + +
+ `; +} diff --git a/src/js/sw/surfaces/login/index.js b/src/js/sw/surfaces/login/index.js new file mode 100644 index 0000000..b5e2e10 --- /dev/null +++ b/src/js/sw/surfaces/login/index.js @@ -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=). + */ +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` + + `; +} + +// ── Mount ──────────────────────────────────── +const mount = document.getElementById('login-mount'); +if (mount) { + render(html`<${LoginSurface} />`, mount); + console.log('[login] Surface mounted'); +} + +export { LoginSurface }; diff --git a/src/js/sw/surfaces/login/login-form.js b/src/js/sw/surfaces/login/login-form.js new file mode 100644 index 0000000..0141cdd --- /dev/null +++ b/src/js/sw/surfaces/login/login-form.js @@ -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` + + `; +} diff --git a/src/js/sw/surfaces/login/register-form.js b/src/js/sw/surfaces/login/register-form.js new file mode 100644 index 0000000..98badef --- /dev/null +++ b/src/js/sw/surfaces/login/register-form.js @@ -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` +
+ + + + + ${error && html``} + +
+ `; +} diff --git a/src/js/sw/surfaces/login/sso-panel.js b/src/js/sw/surfaces/login/sso-panel.js new file mode 100644 index 0000000..7538be1 --- /dev/null +++ b/src/js/sw/surfaces/login/sso-panel.js @@ -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` + + `; + } + + if (mode === 'mtls') { + return html` + + `; + } + + return null; +} diff --git a/src/js/sw/surfaces/settings/appearance.js b/src/js/sw/surfaces/settings/appearance.js new file mode 100644 index 0000000..c1d3396 --- /dev/null +++ b/src/js/sw/surfaces/settings/appearance.js @@ -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` +
+

Theme

+
+ ${THEMES.map(t => html` + + `)} +
+
+
+

UI Scale

+
+
+ setScale(parseInt(e.target.value))} + style="flex:1;" /> + + ${scale}% + +
+
+

Message Font Size

+
+
+ setMsgFont(parseInt(e.target.value))} + style="flex:1;" /> + + ${msgFont}px + +
+
+
+ + `; +} diff --git a/src/js/sw/surfaces/settings/bridge-section.js b/src/js/sw/surfaces/settings/bridge-section.js new file mode 100644 index 0000000..d672f91 --- /dev/null +++ b/src/js/sw/surfaces/settings/bridge-section.js @@ -0,0 +1,32 @@ +/** + * BridgeSection — thin Preact wrapper for deferred settings sections + * + * Renders a container
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` +
+
Loading\u2026
+
+ `; +} diff --git a/src/js/sw/surfaces/settings/general.js b/src/js/sw/surfaces/settings/general.js new file mode 100644 index 0000000..6afcd73 --- /dev/null +++ b/src/js/sw/surfaces/settings/general.js @@ -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` +
+

Chat Defaults

+
+ + +
+
+ +