Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
253 lines
11 KiB
JavaScript
253 lines
11 KiB
JavaScript
/**
|
|
* SettingsSurface — root settings surface
|
|
*
|
|
* Reads globals:
|
|
* __SECTION__ — active section name (string)
|
|
* __BASE__ — base path
|
|
* __CONFIG_SECTIONS__ — v0.38.3: extension config sections (array|null)
|
|
*
|
|
* Policy flags (BYOK, personas) come from sw.auth.policies,
|
|
* populated at SDK boot via GET /api/v1/profile/permissions.
|
|
*
|
|
* Layout: topbar + left nav + content area (same CSS classes as before).
|
|
* All sections are native Preact components loaded lazily.
|
|
*/
|
|
const { html } = window;
|
|
const { useState, useEffect, useCallback, useMemo } = hooks;
|
|
const { render } = preact;
|
|
|
|
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'),
|
|
connections: () => import('./connections.js'),
|
|
personas: () => import('./personas.js'),
|
|
roles: () => import('./roles.js'),
|
|
usage: () => import('./usage.js'),
|
|
workflows: () => import('./workflows.js'),
|
|
tasks: () => import('./tasks.js'),
|
|
gitkeys: () => import('./gitkeys.js'),
|
|
data: () => import('./data.js'),
|
|
knowledge: () => import('./knowledge.js'),
|
|
memory: () => import('./memory.js'),
|
|
notifications: () => import('./notifications.js'),
|
|
};
|
|
|
|
// ── 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: 'Assignments' },
|
|
{ key: 'tasks', label: 'Tasks' },
|
|
{ key: 'connections', label: 'Connections' },
|
|
{ key: 'gitkeys', label: 'Git Keys' },
|
|
{ key: 'knowledge', label: 'Knowledge Bases' },
|
|
{ key: 'memory', label: 'Memory' },
|
|
{ key: 'notifications', label: 'Notifications' },
|
|
{ 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: 'Assignments', tasks: 'Tasks', gitkeys: 'Git Keys',
|
|
connections: 'Connections',
|
|
data: 'Data & Privacy', providers: 'My Providers', roles: 'Model Roles',
|
|
usage: 'My Usage', knowledge: 'Knowledge Bases', memory: 'Memory',
|
|
notifications: 'Notifications',
|
|
};
|
|
|
|
// ── v0.38.3: Extension config sections ──────
|
|
// Packages declare config_section in their manifest. The backend passes
|
|
// matching entries via __CONFIG_SECTIONS__. We merge them into the nav
|
|
// and section module map for lazy loading.
|
|
const _configSections = window.__CONFIG_SECTIONS__ || [];
|
|
const EXT_NAV_ITEMS = _configSections.map(cs => ({ key: cs.package_id, label: cs.label }));
|
|
const _base = window.__BASE__ || '';
|
|
for (const cs of _configSections) {
|
|
const pkgId = cs.package_id;
|
|
const component = cs.component || 'js/config.js';
|
|
sectionModules[pkgId] = () => import(`${_base}/surfaces/${pkgId}/${component}`);
|
|
SECTION_TITLES[pkgId] = cs.label;
|
|
}
|
|
|
|
function SettingsSurface() {
|
|
const BASE = window.__BASE__ || '';
|
|
const section = window.__SECTION__ || 'general';
|
|
|
|
const policies = sw.auth?.policies || {};
|
|
const [byokEnabled, setByokEnabled] = useState(policies.allow_user_byok === 'true');
|
|
const [personasEnabled, setPersonasEnabled] = useState(policies.allow_user_personas === 'true');
|
|
const [SectionComponent, setSectionComponent] = useState(null);
|
|
|
|
// Update when permissions refresh (e.g. after boot or policy change)
|
|
useEffect(() => {
|
|
function _onPermsChanged() {
|
|
const p = sw.auth?.policies || {};
|
|
setByokEnabled(p.allow_user_byok === 'true');
|
|
setPersonasEnabled(p.allow_user_personas === 'true');
|
|
}
|
|
sw.on('auth.permissions.changed', _onPermsChanged);
|
|
return () => sw.off('auth.permissions.changed', _onPermsChanged);
|
|
}, []);
|
|
|
|
// 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>
|
|
`}
|
|
|
|
${EXT_NAV_ITEMS.length > 0 && html`
|
|
<div>
|
|
<div class="settings-nav-sep"></div>
|
|
<div class="settings-nav-label" style="font-size:10px;font-weight:600;color:var(--text-3);padding:8px 12px 4px;text-transform:uppercase;letter-spacing:0.5px;">Extensions</div>
|
|
${EXT_NAV_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>
|
|
|
|
${/* Content */''}
|
|
<div class="settings-content">
|
|
<h2>${title}</h2>
|
|
|
|
${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 };
|