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 `, + `
Consolidation ready. View feedback
`, 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}}