Feat default surface routing (#4)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #4.
This commit is contained in:
30
CHANGELOG.md
30
CHANGELOG.md
@@ -2,7 +2,35 @@
|
|||||||
|
|
||||||
All notable changes to Switchboard Core are documented here.
|
All notable changes to Switchboard Core are documented here.
|
||||||
|
|
||||||
## [Unreleased] — v0.2.0
|
## [Unreleased] — v0.2.1
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Default surface routing**: `/` redirects to configurable default surface.
|
||||||
|
Fallback chain: configured default → first enabled extension surface → `/admin`.
|
||||||
|
First installed extension surface auto-becomes default. Admin can change via
|
||||||
|
Settings > Default Surface dropdown.
|
||||||
|
- `default_surface` global config key (JSON `{"id": "slug"}`)
|
||||||
|
- Admin settings UI: Default Surface dropdown (extension surfaces only)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `disabledRedirect()` now redirects to `/admin` instead of `/` to prevent
|
||||||
|
redirect loops when the default surface is disabled
|
||||||
|
- Disabled extension surfaces (`/s/:slug`) redirect to `/admin` instead of `/`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Gin route param conflict** causing backend startup hang: team-scoped
|
||||||
|
package settings routes used `:pkgId` while sibling routes used `:id`.
|
||||||
|
Gin's radix tree entered an infinite loop on the conflicting param names.
|
||||||
|
Unified to `:id` across all `/teams/:teamId/packages/` routes.
|
||||||
|
- Docker entrypoint: increased health check timeout (10s → 60s), added stale
|
||||||
|
process cleanup and crash detection to prevent zombie backends on restart
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [v0.2.0] — 2026-03-26
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
|||||||
32
ROADMAP.md
32
ROADMAP.md
@@ -48,7 +48,7 @@ SDK stabilization, and the first rebuilt extension (tasks).
|
|||||||
|
|
||||||
| Step | Status | Description |
|
| Step | Status | Description |
|
||||||
|------|--------|-------------|
|
|------|--------|-------------|
|
||||||
| Default surface routing | 🔲 | `/` redirects to configurable default surface. No surfaces → admin. First install becomes default. Changeable in admin settings. |
|
| Default surface routing | ✅ | `/` redirects to configurable default surface. No surfaces → admin. First install becomes default. Changeable in admin settings. |
|
||||||
| ICD (API contract) | 🔲 | OpenAPI spec from registered routes, kernel-only endpoint docs |
|
| ICD (API contract) | 🔲 | OpenAPI spec from registered routes, kernel-only endpoint docs |
|
||||||
|
|
||||||
### v0.2.2 — Event Bus + Triggers
|
### v0.2.2 — Event Bus + Triggers
|
||||||
@@ -65,42 +65,35 @@ SDK stabilization, and the first rebuilt extension (tasks).
|
|||||||
| SDK stabilization | 🔲 | `sb.slots()`, `sb.actions`, `sb.api.ext()`, `sb.storage`, theme tokens, primitive UI |
|
| SDK stabilization | 🔲 | `sb.slots()`, `sb.actions`, `sb.api.ext()`, `sb.storage`, theme tokens, primitive UI |
|
||||||
| Task extension | 🔲 | First proof-of-concept — full task system rebuilt as Starlark extension using triggers + ext_data + notifications |
|
| Task extension | 🔲 | First proof-of-concept — full task system rebuilt as Starlark extension using triggers + ext_data + notifications |
|
||||||
|
|
||||||
## v0.3.0 — Editor Surface
|
## v0.3.0 — Notes Surface
|
||||||
|
|
||||||
The code/markdown editor rebuilt as an installable surface package.
|
Obsidian-style rich-text notes rebuilt as an installable surface package.
|
||||||
Zero platform special-casing. Proves the full extension stack E2E.
|
Zero platform special-casing. Proves the full extension stack E2E.
|
||||||
|
|
||||||
- Editor as `.pkg` archive
|
- Notes as `.pkg` archive
|
||||||
- CM6 integration via surface viewport
|
- Rich text editor (ProseMirror or similar)
|
||||||
- File tree, tabs, preview pane — all extension-provided
|
- Folder tree, backlinks, tags — all extension-provided
|
||||||
|
- Markdown import/export
|
||||||
|
|
||||||
## v0.4.0 — Chat Extension
|
## v0.4.0 — MVP
|
||||||
|
|
||||||
The AI chat system rebuilt as an installable extension package.
|
|
||||||
|
|
||||||
- Provider registry as extension (BYOK chain, model catalog)
|
|
||||||
- Completion streaming via Starlark `provider.complete`
|
|
||||||
- Personas as extension data
|
|
||||||
- Channel/message storage via ext_data tables
|
|
||||||
- Tool system as extension hooks
|
|
||||||
|
|
||||||
## v0.5.0 — MVP
|
|
||||||
|
|
||||||
Extension and operations tracks converge. First externally usable release.
|
Extension and operations tracks converge. First externally usable release.
|
||||||
|
|
||||||
- Package registry (browse, install, update, uninstall)
|
- Package registry (browse, install, update, uninstall)
|
||||||
- Extension marketplace foundations
|
- Package distribution model (no auto-install; explicit install only)
|
||||||
- Health monitoring dashboard
|
- Health monitoring dashboard
|
||||||
- Backup/restore tooling
|
- Backup/restore tooling
|
||||||
- Documentation site
|
- Documentation site
|
||||||
|
|
||||||
## Post-MVP
|
## Post-MVP
|
||||||
|
|
||||||
|
- Chat extension (provider registry, streaming, personas, tool system)
|
||||||
- Rich media extensions: image generation, code sandbox, STT/TTS
|
- Rich media extensions: image generation, code sandbox, STT/TTS
|
||||||
- Desktop app (Tauri or Electron)
|
- Desktop app (Tauri or Electron)
|
||||||
- Sidecar tier: container-based extensions
|
- Sidecar tier: container-based extensions
|
||||||
- Federation: cross-instance package sharing
|
- Federation: cross-instance package sharing
|
||||||
- Plugin marketplace with signing and review
|
- Plugin marketplace with signing and review
|
||||||
|
- Mermaid diagrams extension (nice-to-have)
|
||||||
|
|
||||||
## Design Decisions Log
|
## Design Decisions Log
|
||||||
|
|
||||||
@@ -116,3 +109,6 @@ Extension and operations tracks converge. First externally usable release.
|
|||||||
| Admin → RBAC group | The `role` column is pre-RBAC. v0.2.0 replaces it with a seeded "Admins" group + `surface.admin.access` grant. All users auto-join "Everyone" group. Admin middleware becomes a grant check, not a role check. |
|
| Admin → RBAC group | The `role` column is pre-RBAC. v0.2.0 replaces it with a seeded "Admins" group + `surface.admin.access` grant. All users auto-join "Everyone" group. Admin middleware becomes a grant check, not a role check. |
|
||||||
| Settings cascade | RBAC controls scope auth (who can set at what level). `user_overridable` flag controls whether lower scopes can override higher. Two orthogonal axes, composes cleanly with extension manifests. |
|
| Settings cascade | RBAC controls scope auth (who can set at what level). `user_overridable` flag controls whether lower scopes can override higher. Two orthogonal axes, composes cleanly with extension manifests. |
|
||||||
| No new migrations pre-MVP | Edit existing migration SQL files in place. No migration chains until schema is in production. |
|
| No new migrations pre-MVP | Edit existing migration SQL files in place. No migration chains until schema is in production. |
|
||||||
|
| Notes over Editor | First surface is Obsidian-style notes (rich text, folders, backlinks) instead of a code editor. Notes is a stronger E2E proof — it exercises ext_data, storage, and the SDK more fully than a pure-browser CM6 editor. |
|
||||||
|
| No built-in auto-install | Extensions ship in the repo but are not auto-installed. Distribution model TBD — explicit install only. Keeps the kernel clean and avoids opinionated defaults. |
|
||||||
|
| Chat → post-MVP | Chat extension (providers, streaming, personas) is valuable but not MVP-critical. The platform must prove itself with simpler surfaces first. Chat moves to post-MVP track. |
|
||||||
|
|||||||
@@ -22,19 +22,29 @@ fi
|
|||||||
|
|
||||||
echo "🔀 Starting Switchboard Core backend..."
|
echo "🔀 Starting Switchboard Core backend..."
|
||||||
|
|
||||||
|
# Kill any stale backend from a previous entrypoint run
|
||||||
|
pkill -f /usr/local/bin/switchboard 2>/dev/null || true
|
||||||
|
sleep 0.2
|
||||||
|
|
||||||
# Launch Go backend in background (from /app so migrations are found)
|
# Launch Go backend in background (from /app so migrations are found)
|
||||||
cd /app
|
cd /app
|
||||||
/usr/local/bin/switchboard &
|
/usr/local/bin/switchboard &
|
||||||
BACKEND_PID=$!
|
BACKEND_PID=$!
|
||||||
|
|
||||||
# Wait for backend to be ready (max 10s)
|
# Wait for backend to be ready (max 60s)
|
||||||
for i in $(seq 1 20); do
|
for i in $(seq 1 120); do
|
||||||
if wget -q --spider http://localhost:${PORT:-8080}${BASE_PATH}/health 2>/dev/null; then
|
if wget -q --spider http://localhost:${PORT:-8080}${BASE_PATH}/health 2>/dev/null; then
|
||||||
echo "✅ Backend ready (PID ${BACKEND_PID})"
|
echo "✅ Backend ready (PID ${BACKEND_PID})"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
# Check backend didn't crash
|
||||||
|
if ! kill -0 ${BACKEND_PID} 2>/dev/null; then
|
||||||
|
echo "❌ Backend process died"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "❌ Backend failed to start within 10s"
|
echo "❌ Backend failed to start within 60s"
|
||||||
|
kill ${BACKEND_PID} 2>/dev/null || true
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -553,6 +553,18 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
|||||||
log.Printf("[packages] %s: %d dependencies recorded", pkgID, len(deps))
|
log.Printf("[packages] %s: %d dependencies recorded", pkgID, len(deps))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.2.1: Auto-set default_surface when the first extension surface is installed.
|
||||||
|
if (pkgType == "surface" || pkgType == "full") && pkgSource != "core" {
|
||||||
|
if raw, err := h.stores.GlobalConfig.Get(c.Request.Context(), "default_surface"); err != nil || raw == nil {
|
||||||
|
dflt := models.JSONMap{"id": pkgID}
|
||||||
|
if setErr := h.stores.GlobalConfig.Set(c.Request.Context(), "default_surface", dflt, ""); setErr != nil {
|
||||||
|
log.Printf("[packages] failed to auto-set default_surface to %s: %v", pkgID, setErr)
|
||||||
|
} else {
|
||||||
|
log.Printf("[packages] Auto-set default_surface to %s (first extension surface)", pkgID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"id": pkgID,
|
"id": pkgID,
|
||||||
"title": title,
|
"title": title,
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ func NewTeamPackageSettingsHandler(s store.Stores) *TeamPackageSettingsHandler {
|
|||||||
|
|
||||||
// GetTeamPackageSettings returns the team-scoped settings for a package,
|
// GetTeamPackageSettings returns the team-scoped settings for a package,
|
||||||
// along with the overridable schema keys for the UI.
|
// along with the overridable schema keys for the UI.
|
||||||
// GET /api/v1/teams/:teamId/packages/:pkgId/settings
|
// GET /api/v1/teams/:teamId/packages/:id/settings
|
||||||
func (h *TeamPackageSettingsHandler) GetTeamPackageSettings(c *gin.Context) {
|
func (h *TeamPackageSettingsHandler) GetTeamPackageSettings(c *gin.Context) {
|
||||||
teamID := c.Param("teamId")
|
teamID := c.Param("teamId")
|
||||||
pkgID := c.Param("pkgId")
|
pkgID := c.Param("id")
|
||||||
|
|
||||||
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
|
||||||
if err != nil || pkg == nil {
|
if err != nil || pkg == nil {
|
||||||
@@ -60,10 +60,10 @@ func (h *TeamPackageSettingsHandler) GetTeamPackageSettings(c *gin.Context) {
|
|||||||
|
|
||||||
// UpdateTeamPackageSettings saves team-scoped overrides for a package.
|
// UpdateTeamPackageSettings saves team-scoped overrides for a package.
|
||||||
// Non-overridable keys are stripped before saving.
|
// Non-overridable keys are stripped before saving.
|
||||||
// PUT /api/v1/teams/:teamId/packages/:pkgId/settings
|
// PUT /api/v1/teams/:teamId/packages/:id/settings
|
||||||
func (h *TeamPackageSettingsHandler) UpdateTeamPackageSettings(c *gin.Context) {
|
func (h *TeamPackageSettingsHandler) UpdateTeamPackageSettings(c *gin.Context) {
|
||||||
teamID := c.Param("teamId")
|
teamID := c.Param("teamId")
|
||||||
pkgID := c.Param("pkgId")
|
pkgID := c.Param("id")
|
||||||
|
|
||||||
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
|
||||||
if err != nil || pkg == nil {
|
if err != nil || pkg == nil {
|
||||||
@@ -108,10 +108,10 @@ func (h *TeamPackageSettingsHandler) UpdateTeamPackageSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeleteTeamPackageSettings removes team-scoped overrides for a package.
|
// DeleteTeamPackageSettings removes team-scoped overrides for a package.
|
||||||
// DELETE /api/v1/teams/:teamId/packages/:pkgId/settings
|
// DELETE /api/v1/teams/:teamId/packages/:id/settings
|
||||||
func (h *TeamPackageSettingsHandler) DeleteTeamPackageSettings(c *gin.Context) {
|
func (h *TeamPackageSettingsHandler) DeleteTeamPackageSettings(c *gin.Context) {
|
||||||
teamID := c.Param("teamId")
|
teamID := c.Param("teamId")
|
||||||
pkgID := c.Param("pkgId")
|
pkgID := c.Param("id")
|
||||||
|
|
||||||
if err := h.stores.Packages.DeleteTeamSettings(c.Request.Context(), pkgID, teamID); err != nil {
|
if err := h.stores.Packages.DeleteTeamSettings(c.Request.Context(), pkgID, teamID); err != nil {
|
||||||
c.JSON(500, gin.H{"error": "failed to delete team settings"})
|
c.JSON(500, gin.H{"error": "failed to delete team settings"})
|
||||||
|
|||||||
@@ -478,9 +478,9 @@ func main() {
|
|||||||
|
|
||||||
// Team package settings — cascade overrides (v0.2.0)
|
// Team package settings — cascade overrides (v0.2.0)
|
||||||
teamPkgSettingsH := handlers.NewTeamPackageSettingsHandler(stores)
|
teamPkgSettingsH := handlers.NewTeamPackageSettingsHandler(stores)
|
||||||
teamScoped.GET("/packages/:pkgId/settings", teamPkgSettingsH.GetTeamPackageSettings)
|
teamScoped.GET("/packages/:id/settings", teamPkgSettingsH.GetTeamPackageSettings)
|
||||||
teamScoped.PUT("/packages/:pkgId/settings", teamPkgSettingsH.UpdateTeamPackageSettings)
|
teamScoped.PUT("/packages/:id/settings", teamPkgSettingsH.UpdateTeamPackageSettings)
|
||||||
teamScoped.DELETE("/packages/:pkgId/settings", teamPkgSettingsH.DeleteTeamPackageSettings)
|
teamScoped.DELETE("/packages/:id/settings", teamPkgSettingsH.DeleteTeamPackageSettings)
|
||||||
|
|
||||||
// Team audit log (team admins only — RequireTeamAdmin on group)
|
// Team audit log (team admins only — RequireTeamAdmin on group)
|
||||||
teamScoped.GET("/audit", teams.ListTeamAuditLog)
|
teamScoped.GET("/audit", teams.ListTeamAuditLog)
|
||||||
@@ -683,10 +683,8 @@ func main() {
|
|||||||
pages.SetVersion(Version)
|
pages.SetVersion(Version)
|
||||||
pageEngine := pages.New(cfg, stores)
|
pageEngine := pages.New(cfg, stores)
|
||||||
|
|
||||||
// Root redirect → default surface (admin for now, configurable in v0.2.0)
|
// Root redirect → configurable default surface (v0.2.1)
|
||||||
base.GET("/", func(c *gin.Context) {
|
base.GET("/", pageEngine.DefaultSurfaceRedirect())
|
||||||
c.Redirect(http.StatusTemporaryRedirect, cfg.BasePath+"/admin")
|
|
||||||
})
|
|
||||||
|
|
||||||
// Login page — no auth required
|
// Login page — no auth required
|
||||||
base.GET("/login", pageEngine.RenderLogin())
|
base.GET("/login", pageEngine.RenderLogin())
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !sr.Enabled {
|
if !sr.Enabled {
|
||||||
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/")
|
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/admin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if sr.Source == "core" {
|
if sr.Source == "core" {
|
||||||
|
|||||||
@@ -90,9 +90,77 @@ func (e *Engine) EnabledSurfaceIDs() []string {
|
|||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
// disabledRedirect returns a handler that redirects to the root.
|
// DefaultSurfaceRedirect returns a handler for GET / that redirects to the
|
||||||
func (e *Engine) disabledRedirect() gin.HandlerFunc {
|
// configured default surface, falling back to the first enabled extension
|
||||||
|
// surface, then /admin.
|
||||||
|
func (e *Engine) DefaultSurfaceRedirect() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/")
|
target := e.resolveDefaultSurface(c.Request.Context())
|
||||||
|
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveDefaultSurface returns the path to redirect to (without BasePath).
|
||||||
|
// Priority: configured default_surface → first enabled extension surface → /admin.
|
||||||
|
func (e *Engine) resolveDefaultSurface(ctx context.Context) string {
|
||||||
|
if e.stores.GlobalConfig == nil || e.stores.Packages == nil {
|
||||||
|
return "/admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Check configured default_surface
|
||||||
|
if raw, err := e.stores.GlobalConfig.Get(ctx, "default_surface"); err == nil && raw != nil {
|
||||||
|
if id, ok := raw["id"].(string); ok && id != "" {
|
||||||
|
if path := e.surfacePath(ctx, id); path != "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
// Configured surface is missing or disabled — fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. First enabled extension surface
|
||||||
|
surfaces, err := e.stores.Packages.ListEnabledByType(ctx, "surface")
|
||||||
|
if err == nil {
|
||||||
|
for _, s := range surfaces {
|
||||||
|
if s.Source == "core" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.Enabled {
|
||||||
|
return "/s/" + s.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback
|
||||||
|
return "/admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
// surfacePath returns the URL path for a surface ID, or "" if the surface
|
||||||
|
// doesn't exist or is disabled.
|
||||||
|
func (e *Engine) surfacePath(ctx context.Context, id string) string {
|
||||||
|
pkg, err := e.stores.Packages.Get(ctx, id)
|
||||||
|
if err != nil || pkg == nil || !pkg.Enabled {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Core surfaces live at /{id}, extension surfaces at /s/{slug}
|
||||||
|
if pkg.Source == "core" {
|
||||||
|
// Look up the core surface's base route
|
||||||
|
if sm := e.GetSurface(id); sm != nil {
|
||||||
|
// Use the first alt route (without params) or strip params from Route
|
||||||
|
if len(sm.AltRoutes) > 0 {
|
||||||
|
return sm.AltRoutes[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "/" + id
|
||||||
|
}
|
||||||
|
return "/s/" + id
|
||||||
|
}
|
||||||
|
|
||||||
|
// disabledRedirect returns a handler that redirects to /admin.
|
||||||
|
// Uses /admin (not /) to avoid a redirect loop when the default surface
|
||||||
|
// is the one being disabled.
|
||||||
|
func (e *Engine) disabledRedirect() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/admin")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,21 +7,25 @@ const { useState, useEffect, useCallback } = hooks;
|
|||||||
export default function SettingsSection() {
|
export default function SettingsSection() {
|
||||||
const [cfg, setCfg] = useState({});
|
const [cfg, setCfg] = useState({});
|
||||||
const [models, setModels] = useState([]);
|
const [models, setModels] = useState([]);
|
||||||
|
const [surfaces, setSurfaces] = useState([]);
|
||||||
const [vault, setVault] = useState(null);
|
const [vault, setVault] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [s, m, v] = await Promise.all([
|
const [s, m, v, surfList] = await Promise.all([
|
||||||
sw.api.admin.settings.get(),
|
sw.api.admin.settings.get(),
|
||||||
sw.api.admin.models.list().catch(() => []),
|
sw.api.admin.models.list().catch(() => []),
|
||||||
sw.api.admin.vault.status().catch(() => null),
|
sw.api.admin.vault.status().catch(() => null),
|
||||||
|
sw.api.admin.surfaces.list().catch(() => []),
|
||||||
]);
|
]);
|
||||||
const raw = s || {};
|
const raw = s || {};
|
||||||
const pol = raw.policies || {};
|
const pol = raw.policies || {};
|
||||||
const cfg_ = raw.settings || {};
|
const cfg_ = raw.settings || {};
|
||||||
|
setSurfaces((surfList || []).filter(s => s.enabled));
|
||||||
setCfg({
|
setCfg({
|
||||||
|
default_surface: cfg_.default_surface?.id || '',
|
||||||
allow_registration: pol.allow_registration === 'true',
|
allow_registration: pol.allow_registration === 'true',
|
||||||
registration_default_state: cfg_.registration_default_state || 'pending',
|
registration_default_state: cfg_.registration_default_state || 'pending',
|
||||||
system_prompt: cfg_.system_prompt || '',
|
system_prompt: cfg_.system_prompt || '',
|
||||||
@@ -62,6 +66,9 @@ export default function SettingsSection() {
|
|||||||
async function save() {
|
async function save() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
|
// Default surface
|
||||||
|
await sw.api.admin.settings.update('default_surface', { value: cfg.default_surface ? { id: cfg.default_surface } : null });
|
||||||
|
|
||||||
// Policies
|
// Policies
|
||||||
await sw.api.admin.settings.update('allow_registration', { value: String(cfg.allow_registration) });
|
await sw.api.admin.settings.update('allow_registration', { value: String(cfg.allow_registration) });
|
||||||
await sw.api.admin.settings.update('registration_default_state', { value: cfg.registration_default_state });
|
await sw.api.admin.settings.update('registration_default_state', { value: cfg.registration_default_state });
|
||||||
@@ -128,6 +135,16 @@ export default function SettingsSection() {
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="admin-settings-form">
|
<div class="admin-settings-form">
|
||||||
|
<div class="settings-section"><h3>Default Surface</h3>
|
||||||
|
<div class="form-group"><label>Landing page for /</label>
|
||||||
|
<select value=${cfg.default_surface} onChange=${e => set('default_surface', e.target.value)}>
|
||||||
|
<option value="">-- Auto (first extension surface, then Admin) --</option>
|
||||||
|
${surfaces.filter(s => s.source !== 'core').map(s => html`<option key=${s.id} value=${s.id}>${s.title || s.id}</option>`)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<span class="text-muted" style="font-size:12px;">Where users land when visiting /. Only extension surfaces are listed.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-section"><h3>Registration</h3>
|
<div class="settings-section"><h3>Registration</h3>
|
||||||
<label class="toggle-label"><input type="checkbox" checked=${cfg.allow_registration} onChange=${e => set('allow_registration', e.target.checked)} /><span class="toggle-track"></span><span>Allow new user registration</span></label>
|
<label class="toggle-label"><input type="checkbox" checked=${cfg.allow_registration} onChange=${e => set('allow_registration', e.target.checked)} /><span class="toggle-track"></span><span>Allow new user registration</span></label>
|
||||||
<div class="form-group" style="margin-top:8px;"><label>Default state</label>
|
<div class="form-group" style="margin-top:8px;"><label>Default state</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user