From 6b83edc4492ff00041774761f8f8c39e1e814dc8 Mon Sep 17 00:00:00 2001
From: Marco Mondini <10319061+mondial7@users.noreply.github.com>
Date: Sat, 11 Jul 2026 15:20:01 +0200
Subject: [PATCH] feat(pre-v1): admin bootstrap, security headers, log stream,
onboarding tour
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes #27, #32, #34; adds a first-login onboarding tour.
- #27 Admin bootstrap via ADMIN_EMAIL: the user with this email is (and stays)
the global admin — deterministic, replacing the first-user-becomes-admin race.
Self-healing (promotes on login) and last-admin demotion is already guarded.
- #32 Security hardening: strict Content-Security-Policy + X-Frame-Options,
X-Content-Type-Options, Referrer-Policy, Permissions-Policy on every response.
Removed the one inline `,
+ `
`,
id, id)}
}()
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index 0a081d1..9991d4e 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -9,6 +9,7 @@ import (
"github.com/mondial7/smart-360/internal/auth"
"github.com/mondial7/smart-360/internal/config"
+ "github.com/mondial7/smart-360/internal/logstream"
"github.com/mondial7/smart-360/internal/models"
"github.com/mondial7/smart-360/internal/repo"
"github.com/mondial7/smart-360/internal/view"
@@ -20,11 +21,12 @@ type Handlers struct {
Auth *auth.Service
View *view.Renderer
Cfg *config.Config
+ Logs *logstream.Hub
}
// New constructs a Handlers.
-func New(repos repo.Repositories, authSvc *auth.Service, renderer *view.Renderer, cfg *config.Config) *Handlers {
- return &Handlers{Repos: repos, Auth: authSvc, View: renderer, Cfg: cfg}
+func New(repos repo.Repositories, authSvc *auth.Service, renderer *view.Renderer, cfg *config.Config, logs *logstream.Hub) *Handlers {
+ return &Handlers{Repos: repos, Auth: authSvc, View: renderer, Cfg: cfg, Logs: logs}
}
// page builds the base-layout envelope, pulling the current user and CSRF token
@@ -32,12 +34,13 @@ func New(repos repo.Repositories, authSvc *auth.Service, renderer *view.Renderer
func (h *Handlers) page(r *http.Request, title, active, content string, data any) view.PageData {
u, _ := auth.UserFrom(r.Context())
return view.PageData{
- Title: title,
- Active: active,
- User: u,
- CSRF: h.Auth.CSRFToken(r),
- Content: content,
- Data: data,
+ Title: title,
+ Active: active,
+ User: u,
+ CSRF: h.Auth.CSRFToken(r),
+ Content: content,
+ Data: data,
+ ShowOnboarding: u != nil && u.OnboardedAt == nil,
}
}
diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go
index 0f7bcff..eb3d748 100644
--- a/internal/handlers/handlers_test.go
+++ b/internal/handlers/handlers_test.go
@@ -13,6 +13,7 @@ import (
"github.com/mondial7/smart-360/internal/auth"
"github.com/mondial7/smart-360/internal/config"
"github.com/mondial7/smart-360/internal/handlers"
+ "github.com/mondial7/smart-360/internal/logstream"
"github.com/mondial7/smart-360/internal/models"
"github.com/mondial7/smart-360/internal/repo"
"github.com/mondial7/smart-360/internal/view"
@@ -23,14 +24,14 @@ import (
// these tests exercise routing + middleware + templates without a database.
func newTestServer(t *testing.T) (*httptest.Server, *http.Client, repo.Repositories) {
t.Helper()
- cfg := &config.Config{DevMode: true, SessionSecret: "test-secret"}
+ cfg := &config.Config{DevMode: true, SessionSecret: "test-secret", AdminEmail: "admin@example.com"}
repos := repo.NewFakes()
renderer, err := view.NewRenderer(web.TemplatesFS)
if err != nil {
t.Fatalf("renderer: %v", err)
}
authSvc := auth.New(cfg, repos)
- h := handlers.New(repos, authSvc, renderer, cfg)
+ h := handlers.New(repos, authSvc, renderer, cfg, logstream.New(50))
r := chi.NewRouter()
r.Get("/login", h.LoginPage)
@@ -83,7 +84,7 @@ func TestUnauthenticatedRedirectsToLogin(t *testing.T) {
func TestDevLoginThenDashboard(t *testing.T) {
srv, client, repos := newTestServer(t)
- // dev-login provisions the first user as admin and sets the session cookie.
+ // dev-login provisions the ADMIN_EMAIL user as admin and sets the session cookie.
if code, _ := get(t, client, srv.URL+"/auth/dev-login?email=admin@example.com"); code != http.StatusOK {
t.Fatalf("dev-login final status %d", code)
}
@@ -159,6 +160,30 @@ func TestRoundOwnerSeesRawSubmissionsButReviewerDoesNot(t *testing.T) {
}
}
+func TestOnboardingShownUntilCompleted(t *testing.T) {
+ srv, client, repos := newTestServer(t)
+ ctx := t.Context()
+ if code, _ := get(t, client, srv.URL+"/auth/dev-login?email=newbie@example.com"); code != http.StatusOK {
+ t.Fatalf("dev-login: %d", code)
+ }
+ user, _ := repos.Users.FindByEmail(ctx, "newbie@example.com")
+
+ // First login: the tour overlay is present.
+ _, body := get(t, client, srv.URL+"/")
+ if !strings.Contains(body, `id="onboarding"`) {
+ t.Fatal("expected onboarding overlay on first login")
+ }
+
+ // After completing (marked seen), it no longer renders.
+ if err := repos.Users.MarkOnboarded(ctx, user.ID); err != nil {
+ t.Fatalf("mark onboarded: %v", err)
+ }
+ _, body = get(t, client, srv.URL+"/")
+ if strings.Contains(body, `id="onboarding"`) {
+ t.Fatal("onboarding overlay should not render after completion")
+ }
+}
+
func TestConsolidationStreamRequiresToken(t *testing.T) {
srv, client, repos := newTestServer(t)
ctx := t.Context()
@@ -181,7 +206,7 @@ func TestConsolidationStreamRequiresToken(t *testing.T) {
func TestNonAdminCannotAccessTeams(t *testing.T) {
srv, client, repos := newTestServer(t)
- // First user (admin) provisioned separately so the second is a member.
+ // admin@example.com matches ADMIN_EMAIL → admin; member@example.com → member.
_, _ = get(t, client, srv.URL+"/auth/dev-login?email=admin@example.com")
jar2, _ := cookiejar.New(nil)
diff --git a/internal/handlers/helpers.go b/internal/handlers/helpers.go
index cf2833e..8c0dc29 100644
--- a/internal/handlers/helpers.go
+++ b/internal/handlers/helpers.go
@@ -4,6 +4,7 @@ import (
"context"
"html"
"net/http"
+ "strings"
"time"
"github.com/mondial7/smart-360/internal/models"
@@ -14,14 +15,25 @@ import (
func htmlEscape(s string) string { return html.EscapeString(s) }
// redirect navigates the browser: htmx requests get an HX-Redirect header (so
-// the client does a full navigation), everyone else a 303.
+// the client does a full navigation), everyone else a 303. The target is forced
+// to a same-origin absolute path so it can never become an open redirect.
func redirect(w http.ResponseWriter, r *http.Request, url string) {
+ url = safeLocalPath(url)
if r.Header.Get("HX-Request") != "" {
w.Header().Set("HX-Redirect", url)
w.WriteHeader(http.StatusOK)
return
}
- http.Redirect(w, r, url, http.StatusSeeOther)
+ http.Redirect(w, r, url, http.StatusSeeOther) // #nosec G710 -- url is forced to a same-origin path by safeLocalPath above
+}
+
+// safeLocalPath returns p only if it is a same-origin absolute path; anything
+// else (empty, scheme-relative "//host", or an absolute URL) collapses to "/".
+func safeLocalPath(p string) string {
+ if p == "" || p[0] != '/' || strings.HasPrefix(p, "//") {
+ return "/"
+ }
+ return p
}
// parseDate parses an value ("2006-01-02") into a time, or
diff --git a/internal/handlers/logs.go b/internal/handlers/logs.go
new file mode 100644
index 0000000..fa6a477
--- /dev/null
+++ b/internal/handlers/logs.go
@@ -0,0 +1,63 @@
+package handlers
+
+import (
+ "fmt"
+ "net/http"
+)
+
+// LogsPage renders the admin live-log viewer. It streams the application log
+// over SSE into the browser (see logs.html), and shows the stream endpoint so
+// an admin can point their own tail/consumer at it.
+func (h *Handlers) LogsPage(w http.ResponseWriter, r *http.Request) {
+ data := map[string]any{
+ "Token": h.Auth.StreamToken(r),
+ "LogFormat": h.Cfg.LogFormat,
+ }
+ h.View.Page(w, http.StatusOK, h.page(r, "Logs", "logs", "logs_content", data))
+}
+
+// LogsStream streams application logs as Server-Sent Events: the recent buffer
+// first, then live lines. Admin only, guarded by the session-derived stream
+// token (it's a GET, so the CSRF middleware doesn't cover it).
+func (h *Handlers) LogsStream(w http.ResponseWriter, r *http.Request) {
+ if !h.Auth.ValidStreamToken(r, r.URL.Query().Get("t")) {
+ forbidden(w)
+ return
+ }
+ flusher, ok := w.(http.Flusher)
+ if !ok || h.Logs == nil {
+ http.Error(w, "streaming unsupported", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+
+ // Backfill the recent buffer, then subscribe to live lines.
+ for _, line := range h.Logs.Recent() {
+ writeLogSSE(w, flusher, line)
+ }
+ sub, cancel := h.Logs.Subscribe()
+ defer cancel()
+
+ ctx := r.Context()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case line, ok := <-sub:
+ if !ok {
+ return
+ }
+ writeLogSSE(w, flusher, line)
+ }
+ }
+}
+
+// writeLogSSE emits one log line as an SSE "line" event whose data is an
+// HTML-escaped element appended to the viewer (hx-swap="beforeend").
+func writeLogSSE(w http.ResponseWriter, f http.Flusher, line string) {
+ fmt.Fprintf(w, "event: line\ndata: %s
\n\n", htmlEscape(line))
+ f.Flush()
+}
diff --git a/internal/handlers/pages.go b/internal/handlers/pages.go
index c2f4e6b..fd55c09 100644
--- a/internal/handlers/pages.go
+++ b/internal/handlers/pages.go
@@ -13,6 +13,15 @@ func (h *Handlers) LoginPage(w http.ResponseWriter, r *http.Request) {
h.View.Page(w, http.StatusOK, h.page(r, "Sign in", "", "login_content", data))
}
+// CompleteOnboarding marks the first-login tour as seen and returns an empty
+// body so htmx removes the overlay (hx-swap="outerHTML").
+func (h *Handlers) CompleteOnboarding(w http.ResponseWriter, r *http.Request) {
+ if u := h.user(r); u != nil {
+ _ = h.Repos.Users.MarkOnboarded(r.Context(), u.ID)
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
// Dashboard renders the role-aware landing page.
func (h *Handlers) Dashboard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
diff --git a/internal/handlers/pdf.go b/internal/handlers/pdf.go
index 30dbfd6..6087ed9 100644
--- a/internal/handlers/pdf.go
+++ b/internal/handlers/pdf.go
@@ -51,8 +51,14 @@ func (h *Handlers) DownloadPDF(w http.ResponseWriter, r *http.Request) {
serverError(w, err)
return
}
+ // filename is slugified to [a-z0-9-] by pdf.Filename, so it cannot inject
+ // into the header (CR/LF/quotes are dropped).
+ filename := pdf.Filename(subjectModel.Name, round.CreatedAt.Format("2006-01-02"))
+ disposition := fmt.Sprintf(`attachment; filename=%q`, filename) // #nosec G705 -- filename slugified to [a-z0-9-] upstream, cannot inject
w.Header().Set("Content-Type", "application/pdf")
- w.Header().Set("Content-Disposition",
- fmt.Sprintf(`attachment; filename=%q`, pdf.Filename(subjectModel.Name, round.CreatedAt.Format("2006-01-02"))))
+ w.Header().Set("Content-Disposition", disposition)
+ // #nosec G705 -- (gosec attributes the Content-Disposition taint sink to this
+ // line) the filename is slugified to [a-z0-9-] by pdf.Filename, so it cannot
+ // inject into the header.
_, _ = w.Write(bytes)
}
diff --git a/internal/handlers/routes.go b/internal/handlers/routes.go
index e442a2a..6004ea7 100644
--- a/internal/handlers/routes.go
+++ b/internal/handlers/routes.go
@@ -10,6 +10,9 @@ import (
// the RequireAuth + ProtectCSRF group in the router. submitLimit is a per-IP
// rate-limit middleware applied to the feedback-submission endpoints.
func (h *Handlers) MountAppRoutes(r chi.Router, submitLimit func(http.Handler) http.Handler) {
+ // Onboarding
+ r.Post("/onboarding/complete", h.CompleteOnboarding)
+
// Rounds
r.Get("/rounds", h.RoundsList)
r.With(h.Auth.RequireTeamAdminOrAdmin).Get("/rounds/new", h.NewRoundForm)
@@ -58,4 +61,8 @@ func (h *Handlers) MountAppRoutes(r chi.Router, submitLimit func(http.Handler) h
// Analytics + audit (admin)
r.With(h.Auth.RequireAdmin).Get("/analytics", h.Analytics)
r.With(h.Auth.RequireAdmin).Get("/audit-logs", h.AuditLogs)
+
+ // Live application logs (admin)
+ r.With(h.Auth.RequireAdmin).Get("/admin/logs", h.LogsPage)
+ r.With(h.Auth.RequireAdmin).Get("/admin/logs/stream", h.LogsStream)
}
diff --git a/internal/handlers/submissions.go b/internal/handlers/submissions.go
index 850712c..4f82efd 100644
--- a/internal/handlers/submissions.go
+++ b/internal/handlers/submissions.go
@@ -65,7 +65,7 @@ func (h *Handlers) SubmitForm(w http.ResponseWriter, r *http.Request) {
}
// Already submitted → send to the edit view.
if n, _ := h.Repos.Submissions.CountByRoundAndReviewer(ctx, id, u.ID); n > 0 {
- http.Redirect(w, r, "/rounds/"+id+"/submission", http.StatusSeeOther)
+ http.Redirect(w, r, safeLocalPath("/rounds/"+id+"/submission"), http.StatusSeeOther) // #nosec G710 -- safeLocalPath guarantees a same-origin path
return
}
@@ -142,7 +142,7 @@ func (h *Handlers) EditSubmissionForm(w http.ResponseWriter, r *http.Request) {
}
existing, err := h.Repos.Submissions.FindByRoundAndReviewer(ctx, id, u.ID)
if errors.Is(err, repo.ErrNotFound) {
- http.Redirect(w, r, "/rounds/"+id+"/submit", http.StatusSeeOther)
+ http.Redirect(w, r, safeLocalPath("/rounds/"+id+"/submit"), http.StatusSeeOther) // #nosec G710 -- safeLocalPath guarantees a same-origin path
return
}
if err != nil {
diff --git a/internal/logstream/hub.go b/internal/logstream/hub.go
new file mode 100644
index 0000000..65b844b
--- /dev/null
+++ b/internal/logstream/hub.go
@@ -0,0 +1,84 @@
+// Package logstream captures application log lines into a bounded ring buffer
+// and fans them out to live subscribers. It is an io.Writer, so it slots in
+// behind slog and the standard logger without changing call sites, and it backs
+// the admin "Logs" page (live stream in the browser) plus any consumer that
+// connects to the log-stream endpoint.
+package logstream
+
+import (
+ "strings"
+ "sync"
+)
+
+// Hub buffers recent log lines and broadcasts new ones to subscribers.
+type Hub struct {
+ mu sync.Mutex
+ ring []string
+ size int
+ subs map[chan string]struct{}
+}
+
+// New returns a Hub retaining the last size lines (default 500).
+func New(size int) *Hub {
+ if size <= 0 {
+ size = 500
+ }
+ return &Hub{size: size, subs: make(map[chan string]struct{})}
+}
+
+// Write implements io.Writer: each newline-delimited line is buffered and
+// broadcast. It never blocks the caller (slow subscribers drop lines).
+func (h *Hub) Write(p []byte) (int, error) {
+ for _, line := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") {
+ if strings.TrimSpace(line) == "" {
+ continue
+ }
+ h.publish(line)
+ }
+ return len(p), nil
+}
+
+func (h *Hub) publish(line string) {
+ h.mu.Lock()
+ h.ring = append(h.ring, line)
+ if len(h.ring) > h.size {
+ h.ring = h.ring[len(h.ring)-h.size:]
+ }
+ subs := make([]chan string, 0, len(h.subs))
+ for c := range h.subs {
+ subs = append(subs, c)
+ }
+ h.mu.Unlock()
+
+ for _, c := range subs {
+ select {
+ case c <- line:
+ default: // subscriber is behind; drop rather than block logging
+ }
+ }
+}
+
+// Recent returns a copy of the buffered lines, oldest first.
+func (h *Hub) Recent() []string {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ out := make([]string, len(h.ring))
+ copy(out, h.ring)
+ return out
+}
+
+// Subscribe registers a live subscriber and returns its channel plus a cancel
+// func. The channel is buffered; the cancel func unregisters it (it is not
+// closed, so a concurrent publish can never send on a closed channel — readers
+// stop via their own context).
+func (h *Hub) Subscribe() (<-chan string, func()) {
+ c := make(chan string, 128)
+ h.mu.Lock()
+ h.subs[c] = struct{}{}
+ h.mu.Unlock()
+ return c, func() {
+ h.mu.Lock()
+ delete(h.subs, c)
+ h.mu.Unlock()
+ }
+}
diff --git a/internal/logstream/hub_test.go b/internal/logstream/hub_test.go
new file mode 100644
index 0000000..36cd2bc
--- /dev/null
+++ b/internal/logstream/hub_test.go
@@ -0,0 +1,43 @@
+package logstream
+
+import "testing"
+
+func TestHubRingBufferCap(t *testing.T) {
+ h := New(3)
+ for _, s := range []string{"a", "b", "c", "d"} {
+ _, _ = h.Write([]byte(s + "\n"))
+ }
+ got := h.Recent()
+ if len(got) != 3 || got[0] != "b" || got[2] != "d" {
+ t.Fatalf("expected last 3 [b c d], got %v", got)
+ }
+}
+
+func TestHubBroadcastsToSubscriber(t *testing.T) {
+ h := New(10)
+ sub, cancel := h.Subscribe()
+ defer cancel()
+
+ if _, err := h.Write([]byte("line one\nline two\n")); err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{"line one", "line two"} {
+ select {
+ case got := <-sub:
+ if got != want {
+ t.Fatalf("got %q, want %q", got, want)
+ }
+ default:
+ t.Fatalf("expected %q on the subscriber channel", want)
+ }
+ }
+}
+
+func TestHubWriteSplitsAndSkipsBlank(t *testing.T) {
+ h := New(10)
+ _, _ = h.Write([]byte("x\n\n \ny\n"))
+ got := h.Recent()
+ if len(got) != 2 || got[0] != "x" || got[1] != "y" {
+ t.Fatalf("expected [x y], got %v", got)
+ }
+}
diff --git a/internal/models/user.go b/internal/models/user.go
index 262e8c0..27808b7 100644
--- a/internal/models/user.go
+++ b/internal/models/user.go
@@ -21,15 +21,16 @@ func (r UserRole) IsValid() bool {
}
type User struct {
- ID string `json:"id"`
- Email string `json:"email"`
- Name string `json:"name"`
- PhotoURL string `json:"photoUrl"`
- Role UserRole `json:"role"`
- TeamID *string `json:"teamId,omitempty"`
- CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt"`
- LastLogin *time.Time `json:"lastLogin,omitempty"`
+ ID string `json:"id"`
+ Email string `json:"email"`
+ Name string `json:"name"`
+ PhotoURL string `json:"photoUrl"`
+ Role UserRole `json:"role"`
+ TeamID *string `json:"teamId,omitempty"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+ LastLogin *time.Time `json:"lastLogin,omitempty"`
+ OnboardedAt *time.Time `json:"onboardedAt,omitempty"`
}
func (u User) IsAdmin() bool { return u.Role == RoleAdmin }
diff --git a/internal/pdf/consolidation.go b/internal/pdf/consolidation.go
index 6cf84e3..521f502 100644
--- a/internal/pdf/consolidation.go
+++ b/internal/pdf/consolidation.go
@@ -15,14 +15,29 @@ import (
)
// Filename builds the download filename, e.g. "smart360-jane-doe-2026-07-08.pdf".
+// The subject name is slugified to a strict [a-z0-9-] set so it can never inject
+// into the Content-Disposition header (CR/LF, quotes, etc. are dropped).
func Filename(subjectName, dateStr string) string {
- slug := strings.ToLower(strings.ReplaceAll(subjectName, " ", "-"))
+ slug := slugify(subjectName)
if slug == "" {
slug = "feedback"
}
return fmt.Sprintf("smart360-%s-%s.pdf", slug, dateStr)
}
+func slugify(s string) string {
+ var b strings.Builder
+ for _, r := range strings.ToLower(s) {
+ switch {
+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
+ b.WriteRune(r)
+ case r == ' ' || r == '-' || r == '_':
+ b.WriteByte('-')
+ }
+ }
+ return strings.Trim(b.String(), "-")
+}
+
// Render produces the consolidation PDF bytes for a subject and round.
func Render(subject models.User, round models.FeedbackRound, c models.Consolidation) ([]byte, error) {
pdf := fpdf.New("P", "mm", "A4", "")
diff --git a/internal/repo/fakes.go b/internal/repo/fakes.go
index 8d4200c..b87f154 100644
--- a/internal/repo/fakes.go
+++ b/internal/repo/fakes.go
@@ -116,6 +116,21 @@ func (f *FakeUsers) UpdateLastLogin(_ context.Context, id string) error {
return nil
}
+func (f *FakeUsers) MarkOnboarded(_ context.Context, id string) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ u, ok := f.data[id]
+ if !ok {
+ return ErrNotFound
+ }
+ if u.OnboardedAt == nil {
+ t := now()
+ u.OnboardedAt = &t
+ f.data[id] = u
+ }
+ return nil
+}
+
func (f *FakeUsers) SetTeam(_ context.Context, userID string, teamID *string) error {
f.mu.Lock()
defer f.mu.Unlock()
diff --git a/internal/repo/pg_users.go b/internal/repo/pg_users.go
index 1ad9d04..efc3cea 100644
--- a/internal/repo/pg_users.go
+++ b/internal/repo/pg_users.go
@@ -8,12 +8,12 @@ import (
type pgUsers struct{ q querier }
-const userColumns = `id, email, name, photo_url, role, team_id, created_at, updated_at, last_login`
+const userColumns = `id, email, name, photo_url, role, team_id, created_at, updated_at, last_login, onboarded_at`
func scanUser(row rowScanner) (*models.User, error) {
var u models.User
if err := row.Scan(&u.ID, &u.Email, &u.Name, &u.PhotoURL, &u.Role, &u.TeamID,
- &u.CreatedAt, &u.UpdatedAt, &u.LastLogin); err != nil {
+ &u.CreatedAt, &u.UpdatedAt, &u.LastLogin, &u.OnboardedAt); err != nil {
return nil, normalizeErr(err)
}
return &u, nil
@@ -49,6 +49,12 @@ func (r *pgUsers) UpdateLastLogin(ctx context.Context, id string) error {
return err
}
+func (r *pgUsers) MarkOnboarded(ctx context.Context, id string) error {
+ _, err := r.q.Exec(ctx,
+ `UPDATE users SET onboarded_at = now(), updated_at = now() WHERE id = $1 AND onboarded_at IS NULL`, id)
+ return err
+}
+
func (r *pgUsers) SetTeam(ctx context.Context, userID string, teamID *string) error {
_, err := r.q.Exec(ctx, `UPDATE users SET team_id = $2, updated_at = now() WHERE id = $1`, userID, teamID)
return err
diff --git a/internal/repo/repo.go b/internal/repo/repo.go
index 6f763d0..b17da5f 100644
--- a/internal/repo/repo.go
+++ b/internal/repo/repo.go
@@ -15,6 +15,7 @@ type UserRepository interface {
Create(ctx context.Context, user *models.User) error
UpdateRole(ctx context.Context, id string, role models.UserRole) error
UpdateLastLogin(ctx context.Context, id string) error
+ MarkOnboarded(ctx context.Context, id string) error
SetTeam(ctx context.Context, userID string, teamID *string) error
FindAll(ctx context.Context) ([]models.User, error)
}
diff --git a/internal/view/charts.go b/internal/view/charts.go
index b3ece2e..3e992a2 100644
--- a/internal/view/charts.go
+++ b/internal/view/charts.go
@@ -105,7 +105,7 @@ func RadarSVG(axes []RadarAxis, size, max float64) template.HTML {
}
b.WriteString(``)
- return template.HTML(b.String()) //nolint:gosec // markup built from escaped labels + numeric geometry
+ return template.HTML(b.String()) // #nosec G203 -- markup built from escaped labels + numeric geometry
}
// DonutSlice is one wedge of the donut chart.
@@ -163,7 +163,7 @@ func DonutSVG(slices []DonutSlice, size, thickness float64, centerLabel string)
num(center), num(center+14), template.HTMLEscapeString(centerLabel))
b.WriteString(``)
- return template.HTML(b.String()) //nolint:gosec // markup built from escaped labels + numeric geometry
+ return template.HTML(b.String()) // #nosec G203 -- markup built from escaped labels + numeric geometry
}
// num formats a float for SVG coordinates: up to 2 decimals, trailing zeros
diff --git a/internal/view/render.go b/internal/view/render.go
index 4d9ed23..2daee48 100644
--- a/internal/view/render.go
+++ b/internal/view/render.go
@@ -22,13 +22,14 @@ type Renderer struct {
// PageData is the envelope passed to the base layout. Content names the content
// template to inject; Data is the page-specific payload handed to it.
type PageData struct {
- Title string
- Active string // nav key to highlight
- User *models.User
- CSRF string
- Flash string
- Content string
- Data any
+ Title string
+ Active string // nav key to highlight
+ User *models.User
+ CSRF string
+ Flash string
+ Content string
+ Data any
+ ShowOnboarding bool // render the first-login tour overlay
}
// NewRenderer parses templates from the given FS (expects templates/*.html and
@@ -102,7 +103,7 @@ func (r *Renderer) partial(name string, data any) (template.HTML, error) {
if err := r.root.ExecuteTemplate(&buf, name, data); err != nil {
return "", err
}
- return template.HTML(buf.String()), nil //nolint:gosec // content templates are trusted, data is auto-escaped
+ return template.HTML(buf.String()), nil // #nosec G203 -- content templates are trusted, data is auto-escaped
}
// ---- template funcs ----
diff --git a/web/static/css/app.css b/web/static/css/app.css
index 019487f..840593e 100644
--- a/web/static/css/app.css
+++ b/web/static/css/app.css
@@ -149,3 +149,35 @@ th { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; colo
@keyframes spin { to { transform: rotate(360deg); } }
.manager-only { border: 1px solid #fecaca; background: #fff7f7; }
.manager-only h3 { color: var(--danger); }
+
+/* Onboarding tour */
+.onboarding {
+ position: fixed; inset: 0; z-index: 100;
+ background: rgba(15, 20, 32, 0.72);
+ display: flex; align-items: center; justify-content: center; padding: 1.5rem;
+}
+.onboarding__card {
+ background: var(--surface); border-radius: 16px; box-shadow: 0 20px 60px rgba(0,0,0,0.35);
+ width: 100%; max-width: 460px; padding: 2rem; text-align: center;
+}
+.onboarding__slide { display: none; }
+.onboarding__slide.is-active { display: block; animation: fade-in 0.25s ease; }
+.onboarding__slide h2 { font-size: 1.5rem; margin: 0.25rem 0 0.6rem; }
+.onboarding__slide p { color: var(--text-secondary); margin: 0 auto; max-width: 34ch; }
+.onboarding__emoji { font-size: 2.6rem; line-height: 1; margin-bottom: 0.5rem; }
+.onboarding__nav { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; margin-top: 1.75rem; }
+.onboarding__actions { display: flex; gap: 0.5rem; }
+.onboarding__dots { display: flex; gap: 6px; }
+.onboarding__dots span { width: 7px; height: 7px; border-radius: 50%; background: var(--border-color); transition: background 0.2s, transform 0.2s; }
+.onboarding__dots span.is-active { background: var(--color-primary); transform: scale(1.25); }
+@keyframes fade-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
+
+/* Live log viewer */
+.log-view {
+ max-height: 60vh; overflow: auto; margin: 0;
+ background: #0f1420; color: #d7dce6;
+ padding: 0.75rem; border-radius: var(--radius);
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 0.8rem; line-height: 1.5; white-space: pre-wrap; word-break: break-word;
+}
+.log-line { white-space: pre-wrap; }
diff --git a/web/static/js/app.js b/web/static/js/app.js
new file mode 100644
index 0000000..3149a79
--- /dev/null
+++ b/web/static/js/app.js
@@ -0,0 +1,58 @@
+// Small progressive-enhancement helpers for Smart 360.
+//
+// Kept CSP-friendly: this is a self-hosted script (script-src 'self'), so there
+// are no inline scripts or event handlers anywhere in the templates.
+(function () {
+ "use strict";
+
+ // Navigate when a swapped-in fragment asks for it via [data-redirect].
+ // Used by the SSE consolidation flow: the "done" event swaps in an element
+ // carrying data-redirect, and we send the browser there — no inline script.
+ function handleRedirect() {
+ var el = document.querySelector("[data-redirect]");
+ if (el) {
+ var url = el.getAttribute("data-redirect");
+ el.removeAttribute("data-redirect"); // guard against repeat navigation
+ if (url) {
+ window.location.assign(url);
+ }
+ }
+ }
+
+ // First-login onboarding tour: pure client-side slide navigation. Skip/finish
+ // are htmx POSTs to /onboarding/complete (which removes the overlay); this only
+ // handles Next/Back and the slide/dot/button visibility.
+ function initOnboarding() {
+ var root = document.getElementById("onboarding");
+ if (!root) return;
+
+ var slides = root.querySelectorAll(".onboarding__slide");
+ var dots = root.querySelectorAll(".onboarding__dots span");
+ var prevBtn = root.querySelector('[data-tour="prev"]');
+ var nextBtn = root.querySelector('[data-tour="next"]');
+ var finishBtn = root.querySelector('[data-tour="finish"]');
+ var current = 0;
+
+ function render() {
+ slides.forEach(function (s, i) { s.classList.toggle("is-active", i === current); });
+ dots.forEach(function (d, i) { d.classList.toggle("is-active", i === current); });
+ var last = current === slides.length - 1;
+ if (prevBtn) prevBtn.hidden = current === 0;
+ if (nextBtn) nextBtn.hidden = last;
+ if (finishBtn) finishBtn.hidden = !last;
+ }
+ if (nextBtn) nextBtn.addEventListener("click", function () {
+ if (current < slides.length - 1) { current++; render(); }
+ });
+ if (prevBtn) prevBtn.addEventListener("click", function () {
+ if (current > 0) { current--; render(); }
+ });
+ render();
+ }
+
+ document.addEventListener("DOMContentLoaded", function () {
+ document.body.addEventListener("htmx:afterSwap", handleRedirect);
+ document.body.addEventListener("htmx:oobAfterSwap", handleRedirect);
+ initOnboarding();
+ });
+})();
diff --git a/web/templates/base.html b/web/templates/base.html
index a5d72e8..fefbf84 100644
--- a/web/templates/base.html
+++ b/web/templates/base.html
@@ -9,6 +9,7 @@
+
{{if .User}}
@@ -25,6 +26,7 @@
Users
Analytics
Audit
+ Logs
{{end}}
@@ -38,6 +40,8 @@
{{if .Flash}}
{{.Flash}}
{{end}}
{{ partial .Content .Data }}
+
+ {{if .ShowOnboarding}}{{template "onboarding" .}}{{end}}