Add welcome surface and user default surface preference
- Create welcome surface: topbar + getting-started card shown when no extension surfaces are installed. Admins see a link to Packages, non-admins see a message to contact their administrator. - Update resolveDefaultSurface priority chain: user preference → global config → first extension → /welcome - Register welcome as a core surface with authenticated auth - Final fallback is now /welcome instead of /admin, breaking the infinite back-button loop Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -65,6 +65,7 @@ server {
|
|||||||
location ${BASE_PATH}/admin { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
location ${BASE_PATH}/admin { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||||
location ${BASE_PATH}/team-admin { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
location ${BASE_PATH}/team-admin { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||||
location ${BASE_PATH}/settings { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
location ${BASE_PATH}/settings { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||||
|
location ${BASE_PATH}/welcome { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||||
location ${BASE_PATH}/w/ { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
location ${BASE_PATH}/w/ { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||||
|
|
||||||
# Extension surface page routes → backend
|
# Extension surface page routes → backend
|
||||||
|
|||||||
@@ -244,6 +244,11 @@ func (e *Engine) registerCoreSurfaces() {
|
|||||||
Title: "Workflow Landing", Template: "workflow-landing", Auth: "public",
|
Title: "Workflow Landing", Template: "workflow-landing", Auth: "public",
|
||||||
Layout: "single", Source: "core",
|
Layout: "single", Source: "core",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: "welcome", Route: "/welcome",
|
||||||
|
Title: "Welcome", Template: "surface-welcome", Auth: "authenticated",
|
||||||
|
Layout: "single", Source: "core",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,22 +91,35 @@ func (e *Engine) EnabledSurfaceIDs() []string {
|
|||||||
|
|
||||||
// DefaultSurfaceRedirect returns a handler for GET / that redirects to the
|
// DefaultSurfaceRedirect returns a handler for GET / that redirects to the
|
||||||
// configured default surface, falling back to the first enabled extension
|
// configured default surface, falling back to the first enabled extension
|
||||||
// surface, then /admin.
|
// surface, then /welcome.
|
||||||
func (e *Engine) DefaultSurfaceRedirect() gin.HandlerFunc {
|
func (e *Engine) DefaultSurfaceRedirect() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
target := e.resolveDefaultSurface(c.Request.Context())
|
userID := c.GetString("user_id")
|
||||||
|
target := e.resolveDefaultSurface(c.Request.Context(), userID)
|
||||||
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+target)
|
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveDefaultSurface returns the path to redirect to (without BasePath).
|
// resolveDefaultSurface returns the path to redirect to (without BasePath).
|
||||||
// Priority: configured default_surface → first enabled extension surface → /admin.
|
// Priority: user preference → global default_surface → first enabled extension → /welcome.
|
||||||
func (e *Engine) resolveDefaultSurface(ctx context.Context) string {
|
func (e *Engine) resolveDefaultSurface(ctx context.Context, userID string) string {
|
||||||
if e.stores.GlobalConfig == nil || e.stores.Packages == nil {
|
if e.stores.GlobalConfig == nil || e.stores.Packages == nil {
|
||||||
return "/admin"
|
return "/welcome"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Check configured default_surface
|
// 1. Check user preference (default_surface in user settings)
|
||||||
|
if userID != "" && e.stores.Users != nil {
|
||||||
|
if user, err := e.stores.Users.GetByID(ctx, userID); err == nil && user != nil {
|
||||||
|
if id, _ := user.Settings["default_surface"].(string); id != "" {
|
||||||
|
if path := e.surfacePath(ctx, id); path != "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
// User's chosen surface is missing or disabled — fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check admin-configured global default_surface
|
||||||
if raw, err := e.stores.GlobalConfig.Get(ctx, "default_surface"); err == nil && raw != nil {
|
if raw, err := e.stores.GlobalConfig.Get(ctx, "default_surface"); err == nil && raw != nil {
|
||||||
if id, ok := raw["id"].(string); ok && id != "" {
|
if id, ok := raw["id"].(string); ok && id != "" {
|
||||||
if path := e.surfacePath(ctx, id); path != "" {
|
if path := e.surfacePath(ctx, id); path != "" {
|
||||||
@@ -116,7 +129,7 @@ func (e *Engine) resolveDefaultSurface(ctx context.Context) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. First enabled extension surface
|
// 3. First enabled extension surface
|
||||||
surfaces, err := e.stores.Packages.ListEnabledByType(ctx, "surface")
|
surfaces, err := e.stores.Packages.ListEnabledByType(ctx, "surface")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, s := range surfaces {
|
for _, s := range surfaces {
|
||||||
@@ -129,8 +142,8 @@ func (e *Engine) resolveDefaultSurface(ctx context.Context) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Fallback
|
// 4. Fallback — welcome surface (no extensions installed)
|
||||||
return "/admin"
|
return "/welcome"
|
||||||
}
|
}
|
||||||
|
|
||||||
// surfacePath returns the URL path for a surface ID, or "" if the surface
|
// surfacePath returns the URL path for a surface ID, or "" if the surface
|
||||||
@@ -155,9 +168,8 @@ func (e *Engine) surfacePath(ctx context.Context, id string) string {
|
|||||||
return "/s/" + id
|
return "/s/" + id
|
||||||
}
|
}
|
||||||
|
|
||||||
// disabledRedirect returns a handler that redirects to /admin.
|
// disabledRedirect returns a handler that redirects to /.
|
||||||
// Uses /admin (not /) to avoid a redirect loop when the default surface
|
// The DefaultSurfaceRedirect handler will resolve to the appropriate surface.
|
||||||
// is the one being disabled.
|
|
||||||
func (e *Engine) disabledRedirect() gin.HandlerFunc {
|
func (e *Engine) disabledRedirect() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/admin")
|
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/admin")
|
||||||
|
|||||||
@@ -89,6 +89,7 @@
|
|||||||
{{if eq .Surface "admin"}}{{template "surface-admin" .}}
|
{{if eq .Surface "admin"}}{{template "surface-admin" .}}
|
||||||
{{else if eq .Surface "team-admin"}}{{template "surface-team-admin" .}}
|
{{else if eq .Surface "team-admin"}}{{template "surface-team-admin" .}}
|
||||||
{{else if eq .Surface "settings"}}{{template "surface-settings" .}}
|
{{else if eq .Surface "settings"}}{{template "surface-settings" .}}
|
||||||
|
{{else if eq .Surface "welcome"}}{{template "surface-welcome" .}}
|
||||||
{{else if and .Manifest (eq .Manifest.Source "extension")}}{{template "surface-extension" .}}
|
{{else if and .Manifest (eq .Manifest.Source "extension")}}{{template "surface-extension" .}}
|
||||||
{{else}}<div style="padding:20px">Unknown surface: {{.Surface}}</div>
|
{{else}}<div style="padding:20px">Unknown surface: {{.Surface}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -123,6 +124,7 @@
|
|||||||
{{if eq .Surface "admin"}}{{template "scripts-admin" .}}{{end}}
|
{{if eq .Surface "admin"}}{{template "scripts-admin" .}}{{end}}
|
||||||
{{if eq .Surface "team-admin"}}{{template "scripts-team-admin" .}}{{end}}
|
{{if eq .Surface "team-admin"}}{{template "scripts-team-admin" .}}{{end}}
|
||||||
{{if eq .Surface "settings"}}{{template "scripts-settings" .}}{{end}}
|
{{if eq .Surface "settings"}}{{template "scripts-settings" .}}{{end}}
|
||||||
|
{{if eq .Surface "welcome"}}{{template "scripts-welcome" .}}{{end}}
|
||||||
{{/* v0.27.0: Extension surface JS — loaded from /surfaces/{id}/js/main.js */}}
|
{{/* v0.27.0: Extension surface JS — loaded from /surfaces/{id}/js/main.js */}}
|
||||||
{{if and .Manifest (eq .Manifest.Source "extension")}}
|
{{if and .Manifest (eq .Manifest.Source "extension")}}
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/surfaces/{{.Surface}}/js/main.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/surfaces/{{.Surface}}/js/main.js?v={{.Version}}"></script>
|
||||||
|
|||||||
25
server/pages/templates/surfaces/welcome.html
Normal file
25
server/pages/templates/surfaces/welcome.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{{/*
|
||||||
|
Welcome surface — shown when no extension surfaces are installed.
|
||||||
|
Renders topbar + welcome card with getting-started info.
|
||||||
|
*/}}
|
||||||
|
|
||||||
|
{{define "surface-welcome"}}
|
||||||
|
<div id="welcome-mount" style="display:flex;flex-direction:column;height:100%;"></div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "scripts-welcome"}}
|
||||||
|
<script type="module" nonce="{{.CSPNonce}}">
|
||||||
|
const { h, render } = await import('{{.BasePath}}/js/sw/vendor/preact.module.js');
|
||||||
|
const hooksModule = await import('{{.BasePath}}/js/sw/vendor/hooks.module.js');
|
||||||
|
const { default: htm } = await import('{{.BasePath}}/js/sw/vendor/htm.module.js');
|
||||||
|
const html = htm.bind(h);
|
||||||
|
window.preact = window.preact || { h, render };
|
||||||
|
window.hooks = window.hooks || hooksModule;
|
||||||
|
window.html = window.html || html;
|
||||||
|
|
||||||
|
const { boot } = await import('{{.BasePath}}/js/sw/sdk/index.js?v={{.Version}}');
|
||||||
|
await boot();
|
||||||
|
|
||||||
|
await import('{{.BasePath}}/js/sw/surfaces/welcome/index.js?v={{.Version}}');
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
43
src/js/sw/surfaces/welcome/index.js
Normal file
43
src/js/sw/surfaces/welcome/index.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* WelcomeSurface — shown when no extension surfaces are installed.
|
||||||
|
* Renders Topbar + a getting-started card with link to admin.
|
||||||
|
*/
|
||||||
|
const { html } = window;
|
||||||
|
const { render } = preact;
|
||||||
|
|
||||||
|
import { Topbar } from '../../shell/topbar.js';
|
||||||
|
|
||||||
|
function WelcomeSurface() {
|
||||||
|
const BASE = window.__BASE__ || '';
|
||||||
|
const isAdmin = sw.can?.('surface.admin.access');
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<${Topbar} title="Switchboard" />
|
||||||
|
<div style="flex:1;display:flex;align-items:center;justify-content:center;padding:24px;">
|
||||||
|
<div style="max-width:420px;width:100%;background:var(--bg-surface);border:1px solid var(--border);border-radius:12px;padding:32px;text-align:center;">
|
||||||
|
<div style="font-size:36px;margin-bottom:16px;">📦</div>
|
||||||
|
<h2 style="font-size:20px;font-weight:600;margin:0 0 8px;">Welcome to Switchboard</h2>
|
||||||
|
<p style="font-size:14px;color:var(--text-2);margin:0 0 24px;line-height:1.5;">
|
||||||
|
No surfaces are installed yet. Install an extension surface
|
||||||
|
to get started.
|
||||||
|
</p>
|
||||||
|
${isAdmin && html`
|
||||||
|
<a href="${BASE}/admin/packages"
|
||||||
|
style="display:inline-flex;align-items:center;gap:8px;padding:10px 24px;background:var(--accent);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;font-size:14px;">
|
||||||
|
Go to Packages
|
||||||
|
</a>
|
||||||
|
`}
|
||||||
|
${!isAdmin && html`
|
||||||
|
<p style="font-size:13px;color:var(--text-3);">
|
||||||
|
Ask your administrator to install a surface package.
|
||||||
|
</p>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mount = document.getElementById('welcome-mount');
|
||||||
|
if (mount) {
|
||||||
|
render(html`<${WelcomeSurface} />`, mount);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user