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:
2026-03-27 13:37:29 +00:00
parent 497251cf59
commit e97bbbb601
6 changed files with 100 additions and 12 deletions

View File

@@ -244,6 +244,11 @@ func (e *Engine) registerCoreSurfaces() {
Title: "Workflow Landing", Template: "workflow-landing", Auth: "public",
Layout: "single", Source: "core",
},
{
ID: "welcome", Route: "/welcome",
Title: "Welcome", Template: "surface-welcome", Auth: "authenticated",
Layout: "single", Source: "core",
},
}
}

View File

@@ -91,22 +91,35 @@ func (e *Engine) EnabledSurfaceIDs() []string {
// DefaultSurfaceRedirect returns a handler for GET / that redirects to the
// configured default surface, falling back to the first enabled extension
// surface, then /admin.
// surface, then /welcome.
func (e *Engine) DefaultSurfaceRedirect() gin.HandlerFunc {
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)
}
}
// 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 {
// Priority: user preference → global default_surface → first enabled extension → /welcome.
func (e *Engine) resolveDefaultSurface(ctx context.Context, userID string) string {
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 id, ok := raw["id"].(string); ok && id != "" {
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")
if err == nil {
for _, s := range surfaces {
@@ -129,8 +142,8 @@ func (e *Engine) resolveDefaultSurface(ctx context.Context) string {
}
}
// 3. Fallback
return "/admin"
// 4. Fallback — welcome surface (no extensions installed)
return "/welcome"
}
// 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
}
// 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.
// disabledRedirect returns a handler that redirects to /.
// The DefaultSurfaceRedirect handler will resolve to the appropriate surface.
func (e *Engine) disabledRedirect() gin.HandlerFunc {
return func(c *gin.Context) {
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/admin")

View File

@@ -89,6 +89,7 @@
{{if eq .Surface "admin"}}{{template "surface-admin" .}}
{{else if eq .Surface "team-admin"}}{{template "surface-team-admin" .}}
{{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}}<div style="padding:20px">Unknown surface: {{.Surface}}</div>
{{end}}
@@ -123,6 +124,7 @@
{{if eq .Surface "admin"}}{{template "scripts-admin" .}}{{end}}
{{if eq .Surface "team-admin"}}{{template "scripts-team-admin" .}}{{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 */}}
{{if and .Manifest (eq .Manifest.Source "extension")}}
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/surfaces/{{.Surface}}/js/main.js?v={{.Version}}"></script>

View 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}}