diff --git a/.env.example b/.env.example index ab333b1..fa3de95 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,14 @@ APP_URL=http://localhost:8080 # any user by knowing their email. Leave false (or unset) in production. DEV_MODE=true +# Bootstrap owner: the user who signs in with this email is (and stays) a global +# admin. Deterministic — set this to your email before first sign-in. When empty, +# no admin is auto-assigned. +ADMIN_EMAIL=admin@example.com + +# Log handler: "text" (default, human-readable) or "json" (for log shippers). +LOG_FORMAT=text + # ========================================== # Database (Postgres) # ========================================== diff --git a/CLAUDE.md b/CLAUDE.md index 117b1f3..d35f0b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -171,8 +171,8 @@ go run ./cmd/server # migrates + seeds + serves :8080 ``` Open . With `DEV_MODE=true`, sign in via the dev-login on -the login page (or `GET /auth/dev-login?email=you@example.com`). The **first user -to sign in becomes admin**; everyone else starts as a member. +the login page (or `GET /auth/dev-login?email=you@example.com`). The user whose +email matches **`ADMIN_EMAIL`** is the admin; everyone else starts as a member. --- @@ -184,8 +184,10 @@ to sign in becomes admin**; everyone else starts as a member. | `APP_URL` | `http://localhost:8080` | external base URL | | `DATABASE_URL` | local compose DSN | Postgres connection string | | `SESSION_SECRET` | dev fallback if `DEV_MODE` | required in prod; signs cookies | +| `ADMIN_EMAIL` | empty | the user with this email is (and stays) the global admin; deterministic bootstrap | | `GOOGLE_CLIENT_ID` / `_SECRET` / `_REDIRECT_URL` | — | OAuth | | `GEMINI_API_KEY` | empty | empty → moderation no-op + non-AI fallback | +| `LOG_FORMAT` | `text` | `text` or `json` (structured logs for shippers) | | `DEV_MODE` | `false` | enables `/auth/dev-login`, relaxes `Secure`, dev fallbacks. **Never true in prod.** | --- diff --git a/README.md b/README.md index 95aee15..002da1c 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ non-AI combine that stitches the raw answers together honestly. | Role | Can do | |------|--------| -| **Admin** | Everything — manage users + teams, create any round, view all rounds, generate and share any consolidation. First user to sign in is auto-promoted. | +| **Admin** | Everything — manage users + teams, create any round, view all rounds, generate and share any consolidation. Bootstrapped via `ADMIN_EMAIL`. | | **Team Admin** | Manage their team's members; create and manage rounds. | | **Member** | Submit feedback when assigned as reviewer, read consolidations shared with them (in-app + PDF). | @@ -134,7 +134,7 @@ go run ./cmd/server # migrates + seeds + serves :8080 Open . With `DEV_MODE=true` you can sign in from the login page's dev-login (or `GET /auth/dev-login?email=you@example.com`) without Google -credentials. **The first user to sign in becomes admin.** +credentials. **Set `ADMIN_EMAIL` to your email** — that account is the admin. ### Full stack in Docker @@ -159,6 +159,8 @@ runs). See [`.env.example`](.env.example) for the annotated list. | `APP_URL` | No | `http://localhost:8080` | External base URL. | | `DATABASE_URL` | Yes | local compose DSN | Postgres connection string. | | `SESSION_SECRET` | **Yes** (prod) | dev fallback if `DEV_MODE` | Signs session cookies. Generate with `openssl rand -hex 32`. | +| `ADMIN_EMAIL` | Recommended | — | The user who signs in with this email is the global admin (deterministic bootstrap). | +| `LOG_FORMAT` | No | `text` | `text` or `json` (structured logs for a log shipper). | | `GOOGLE_CLIENT_ID` | For Google login | — | OAuth client ID. | | `GOOGLE_CLIENT_SECRET` | For Google login | — | OAuth client secret. | | `GOOGLE_REDIRECT_URL` | No | `http://localhost:8080/auth/callback` | Must match the Google Cloud Console redirect URI. | diff --git a/SECURITY.md b/SECURITY.md index 126cf62..4d8f2d4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -38,12 +38,30 @@ What helps triage move quickly: never travel in a URL. Sessions are revocable and expire server-side. - Every state-changing request (POST/PUT/DELETE) is **CSRF-protected** with a per-session token (`X-CSRF-Token` header for htmx, hidden `csrf_token` field - for forms). + for forms). The one state-changing GET (the consolidation/log SSE streams) is + guarded by a separate session-derived stream token. +- **Admin bootstrap** is deterministic: the user whose email matches + `ADMIN_EMAIL` is (and stays) the global admin. There is no "first user wins" + race. All other role changes go through the admin Users page, which refuses to + demote the last admin. - `dev-login` (which bypasses OAuth) is only mounted when `DEV_MODE=true` and is the single most important thing to keep disabled in production. See [ADR-0004](docs/adr/0004-session-cookie-auth.md) for the rationale. +## Transport & browser hardening + +- **Security headers** on every response: a strict `Content-Security-Policy` + (`script-src 'self'` — all JS is self-hosted, no inline scripts), + `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, + `Referrer-Policy: strict-origin-when-cross-origin`, and a restrictive + `Permissions-Policy`. +- **Rate limiting** (per-IP, in-memory): tight on auth endpoints, a cap on + feedback submissions, and a backstop on all authenticated routes. A reverse + proxy should still throttle in front for production. +- Static analysis: `govulncheck` and `gosec` run clean (reviewed false positives + are annotated with `#nosec` + justification). + --- ## Anonymity & privacy by design @@ -150,8 +168,9 @@ Things the application can't enforce on your behalf: - [ ] Terminate TLS at a reverse proxy (Caddy / nginx + Let's Encrypt). OAuth requires HTTPS in production; set `APP_URL` / `GOOGLE_REDIRECT_URL` to the public HTTPS URLs. -- [ ] Throttle abusive traffic at the reverse proxy (`rate_limit` in Caddy, - `limit_req` in nginx). +- [ ] Set `ADMIN_EMAIL` to the owner's address before first sign-in. +- [ ] The app rate-limits per IP already; for extra depth, also throttle at the + reverse proxy (`rate_limit` in Caddy, `limit_req` in nginx). - [ ] Schedule an off-host `pg_dump` backup. - [ ] **Never** set `DEV_MODE=true` in production — it unlocks `dev-login` and relaxes the Secure cookie flag. diff --git a/cmd/server/main.go b/cmd/server/main.go index c51e62f..e2d1348 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -6,7 +6,9 @@ import ( "context" "errors" "fmt" + "io" "log" + "log/slog" "net/http" "os" "os/signal" @@ -17,6 +19,7 @@ import ( "github.com/mondial7/smart-360/internal/config" "github.com/mondial7/smart-360/internal/db" "github.com/mondial7/smart-360/internal/handlers" + "github.com/mondial7/smart-360/internal/logstream" "github.com/mondial7/smart-360/internal/repo" "github.com/mondial7/smart-360/internal/view" "github.com/mondial7/smart-360/web" @@ -48,6 +51,19 @@ func run() error { return err } + // Logging: slog (text or JSON) tee'd to stderr and the in-memory log hub + // that backs the admin Logs page. The standard logger is redirected to the + // same writer so chi's request logs and any log.Print calls are captured too. + logs := logstream.New(500) + logOut := io.MultiWriter(os.Stderr, logs) + slog.SetDefault(slog.New(logHandler(cfg.LogFormat, logOut))) + log.SetFlags(0) + log.SetOutput(logOut) + + if cfg.AdminEmail == "" { + slog.Warn("ADMIN_EMAIL is not set — no admin will be auto-assigned; set it to bootstrap the owner") + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() @@ -60,7 +76,7 @@ func run() error { if err := db.Migrate(ctx, pool); err != nil { return err } - log.Println("migrations applied") + slog.Info("migrations applied") if err := db.Seed(ctx, pool, cfg.DevMode); err != nil { return err @@ -72,7 +88,7 @@ func run() error { return err } authSvc := auth.New(cfg, repos) - h := handlers.New(repos, authSvc, renderer, cfg) + h := handlers.New(repos, authSvc, renderer, cfg, logs) srv := &http.Server{ Addr: ":" + cfg.Port, @@ -81,17 +97,26 @@ func run() error { } go func() { - log.Printf("listening on :%s (dev_mode=%v)", cfg.Port, cfg.DevMode) + slog.Info("listening", "addr", ":"+cfg.Port, "dev_mode", cfg.DevMode) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Printf("server error: %v", err) + slog.Error("server error", "err", err) stop() } }() <-ctx.Done() - log.Println("shutting down") + slog.Info("shutting down") shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() return srv.Shutdown(shutdownCtx) } + +// logHandler builds an slog handler for the configured format. +func logHandler(format string, w io.Writer) slog.Handler { + opts := &slog.HandlerOptions{Level: slog.LevelInfo} + if format == "json" { + return slog.NewJSONHandler(w, opts) + } + return slog.NewTextHandler(w, opts) +} diff --git a/cmd/server/router.go b/cmd/server/router.go index 126c915..d64bc63 100644 --- a/cmd/server/router.go +++ b/cmd/server/router.go @@ -22,6 +22,7 @@ func newRouter(cfg *config.Config, authSvc *auth.Service, h *handlers.Handlers) r.Use(middleware.RealIP) r.Use(middleware.Logger) r.Use(middleware.Recoverer) + r.Use(securityHeaders) // Per-IP rate limits (in-memory). These are a backstop against brute force // and abuse; a production deployment behind a proxy should still throttle @@ -62,3 +63,30 @@ func newRouter(cfg *config.Config, authSvc *auth.Service, h *handlers.Handlers) return r } + +// contentSecurityPolicy is intentionally strict: all scripts are self-hosted +// (htmx, sse, app.js), so no 'unsafe-inline' for scripts. Inline style="…" +// attributes in templates require 'unsafe-inline' for styles only. SSE is +// same-origin (connect-src 'self'). +const contentSecurityPolicy = "default-src 'self'; " + + "script-src 'self'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data: https:; " + + "connect-src 'self'; " + + "font-src 'self'; " + + "form-action 'self'; " + + "base-uri 'self'; " + + "frame-ancestors 'none'" + +// securityHeaders sets defensive response headers on every response. +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Content-Security-Policy", contentSecurityPolicy) + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "DENY") + h.Set("Referrer-Policy", "strict-origin-when-cross-origin") + h.Set("Permissions-Policy", "geolocation=(), camera=(), microphone=()") + next.ServeHTTP(w, r) + }) +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d1249c5..b028dd0 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -34,6 +34,8 @@ services: DATABASE_URL: postgres://${POSTGRES_USER:-smart360}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-smart360}?sslmode=disable SESSION_SECRET: ${SESSION_SECRET:?set SESSION_SECRET} APP_URL: ${APP_URL} + ADMIN_EMAIL: ${ADMIN_EMAIL:?set ADMIN_EMAIL to bootstrap the admin} + LOG_FORMAT: ${LOG_FORMAT:-json} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} GOOGLE_REDIRECT_URL: ${GOOGLE_REDIRECT_URL} diff --git a/docker-compose.yml b/docker-compose.yml index c5ab292..bcf463e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,8 @@ services: DATABASE_URL: postgres://${POSTGRES_USER:-smart360}:${POSTGRES_PASSWORD:-smart360}@postgres:5432/${POSTGRES_DB:-smart360}?sslmode=disable SESSION_SECRET: ${SESSION_SECRET} APP_URL: ${APP_URL:-http://localhost:8080} + ADMIN_EMAIL: ${ADMIN_EMAIL} + LOG_FORMAT: ${LOG_FORMAT:-text} GEMINI_API_KEY: ${GEMINI_API_KEY} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} diff --git a/docs/deployment-production.md b/docs/deployment-production.md index dfb6101..89dacc1 100644 --- a/docs/deployment-production.md +++ b/docs/deployment-production.md @@ -85,8 +85,15 @@ DATABASE_URL=postgres://smart360:@localhost:5432/smart360?sslmode=disa SESSION_SECRET= +# The account that signs in with this email becomes the admin. Set it before +# first sign-in. +ADMIN_EMAIL=you@example.com + APP_URL=https://feedback.example.com +# Structured logs for a shipper (optional): text | json +LOG_FORMAT=json + GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_REDIRECT_URL=https://feedback.example.com/auth/callback @@ -197,7 +204,9 @@ page. sudo systemctl restart smart360 ``` -Sign in. The first user to authenticate is promoted to **Administrator**. +Sign in with the address you set as `ADMIN_EMAIL` — that account is the +**Administrator**. Everyone else is a member until you promote them from the +Users page. --- diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 7eca627..782b17e 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -35,6 +35,34 @@ func issueCookie(t *testing.T, s *Service, userID string) *http.Cookie { return nil } +func TestAdminEmailBootstrap(t *testing.T) { + ctx := context.Background() + cfg := &config.Config{SessionSecret: "s", DevMode: true, AdminEmail: "Owner@Example.com"} + repos := repo.NewFakes() + s := New(cfg, repos) + + // New owner (case-insensitive match) is provisioned as admin. + owner, err := s.upsertUser(ctx, "owner@example.com", "Owner", "") + if err != nil || owner.Role != models.RoleAdmin { + t.Fatalf("expected owner to be admin, got role=%q err=%v", owner.Role, err) + } + // Anyone else is a member. + other, _ := s.upsertUser(ctx, "someone@example.com", "Someone", "") + if other.Role != models.RoleMember { + t.Fatalf("expected member, got %q", other.Role) + } + + // Self-healing: an owner account that predates ADMIN_EMAIL (or was demoted) + // is promoted back to admin on next login. + demoted := &models.User{Email: "owner2@example.com", Role: models.RoleMember} + _ = repos.Users.Create(ctx, demoted) + cfg.AdminEmail = "owner2@example.com" + got, err := s.upsertUser(ctx, "owner2@example.com", "Owner Two", "") + if err != nil || got.Role != models.RoleAdmin { + t.Fatalf("expected self-healing promotion to admin, got role=%q err=%v", got.Role, err) + } +} + func TestSessionRoundTrip(t *testing.T) { s, repos := newTestService(t) u := &models.User{Email: "a@example.com", Name: "Ann", Role: models.RoleAdmin} diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index d9e0db5..10ff273 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "net/http" + "strings" "time" "github.com/mondial7/smart-360/internal/models" @@ -31,7 +32,7 @@ func (s *Service) StartGoogleLogin(w http.ResponseWriter, r *http.Request) { http.Error(w, "Failed to initialize login", http.StatusInternalServerError) return } - http.SetCookie(w, &http.Cookie{ + http.SetCookie(w, &http.Cookie{ // #nosec G124 -- Secure is set in production; relaxed only when DEV_MODE (dev over plain http) Name: oauthStateCookie, Value: state, Path: "/", @@ -51,7 +52,7 @@ func (s *Service) GoogleCallback(w http.ResponseWriter, r *http.Request) { // Validate and always clear the state cookie. state := r.URL.Query().Get("state") stateCookie, cookieErr := r.Cookie(oauthStateCookie) - http.SetCookie(w, &http.Cookie{Name: oauthStateCookie, Value: "", Path: "/", MaxAge: -1, + http.SetCookie(w, &http.Cookie{Name: oauthStateCookie, Value: "", Path: "/", MaxAge: -1, // #nosec G124 -- clearing cookie; Secure relaxed only when DEV_MODE HttpOnly: true, Secure: s.secureCookies(), SameSite: http.SameSiteLaxMode}) if cookieErr != nil || state == "" || subtle.ConstantTimeCompare([]byte(state), []byte(stateCookie.Value)) != 1 { @@ -113,19 +114,15 @@ func (s *Service) DevLogin(w http.ResponseWriter, r *http.Request) { email = "dev@example.com" } - user, err := s.repos.Users.FindByEmail(ctx, email) - if errors.Is(err, repo.ErrNotFound) { - name := "Dev User" - if email == "dev@example.com" { - name = "Dev Admin" - } - user, err = s.provisionUser(ctx, email, name, "") + name := "Dev User" + if email == "dev@example.com" { + name = "Dev Admin" } + user, err := s.upsertUser(ctx, email, name, "") if err != nil { http.Error(w, "Dev login failed", http.StatusInternalServerError) return } - _ = s.repos.Users.UpdateLastLogin(ctx, user.ID) if err := s.issueSession(ctx, w, user.ID); err != nil { http.Error(w, "Failed to start session", http.StatusInternalServerError) return @@ -139,12 +136,20 @@ func (s *Service) Logout(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/login", http.StatusFound) } -// upsertUser finds a user by email or creates one, then updates last_login. +// upsertUser finds a user by email or creates one, keeps the configured owner +// promoted to admin (self-healing), then updates last_login. func (s *Service) upsertUser(ctx context.Context, email, name, photo string) (*models.User, error) { user, err := s.repos.Users.FindByEmail(ctx, email) switch { case err == nil: - // keep existing role; refresh display fields is out of scope here + // Bootstrap owner stays admin even if created before ADMIN_EMAIL was set + // (or was demoted). This is the only automatic promotion. + if s.isAdminEmail(email) && user.Role != models.RoleAdmin { + if err := s.repos.Users.UpdateRole(ctx, user.ID, models.RoleAdmin); err != nil { + return nil, err + } + user.Role = models.RoleAdmin + } case errors.Is(err, repo.ErrNotFound): user, err = s.provisionUser(ctx, email, name, photo) if err != nil { @@ -157,11 +162,11 @@ func (s *Service) upsertUser(ctx context.Context, email, name, photo string) (*m return user, nil } -// provisionUser creates a new user. The very first user in the system becomes -// the global admin; everyone else starts as a member. +// provisionUser creates a new user. Role is deterministic: the configured +// ADMIN_EMAIL becomes admin, everyone else a member. No first-user race. func (s *Service) provisionUser(ctx context.Context, email, name, photo string) (*models.User, error) { role := models.RoleMember - if existing, err := s.repos.Users.FindAll(ctx); err == nil && len(existing) == 0 { + if s.isAdminEmail(email) { role = models.RoleAdmin } now := time.Now() @@ -172,6 +177,11 @@ func (s *Service) provisionUser(ctx context.Context, email, name, photo string) return user, nil } +// isAdminEmail reports whether email is the configured bootstrap owner. +func (s *Service) isAdminEmail(email string) bool { + return s.cfg.AdminEmail != "" && strings.EqualFold(strings.TrimSpace(email), s.cfg.AdminEmail) +} + func randomToken() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { diff --git a/internal/auth/session.go b/internal/auth/session.go index 18c0a99..665c43c 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -27,7 +27,7 @@ func (s *Service) issueSession(ctx context.Context, w http.ResponseWriter, userI if err := s.repos.Sessions.Create(ctx, session); err != nil { return err } - http.SetCookie(w, &http.Cookie{ + http.SetCookie(w, &http.Cookie{ // #nosec G124 -- Secure is set in production; relaxed only when DEV_MODE (dev over plain http) Name: sessionCookieName, Value: s.signSessionID(session.ID), Path: "/", @@ -45,7 +45,7 @@ func (s *Service) clearSession(ctx context.Context, w http.ResponseWriter, r *ht if id, ok := s.readSessionID(r); ok { _ = s.repos.Sessions.Delete(ctx, id) } - http.SetCookie(w, &http.Cookie{ + http.SetCookie(w, &http.Cookie{ // #nosec G124 -- Secure is set in production; relaxed only when DEV_MODE (dev over plain http) Name: sessionCookieName, Value: "", Path: "/", diff --git a/internal/config/config.go b/internal/config/config.go index 4f0bbd1..43c62fd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,10 +20,19 @@ type Config struct { // AppURL is the externally reachable base URL (used for OAuth redirects). AppURL string + // AdminEmail bootstraps the owner: the user who signs in with this email is + // (and stays) a global admin. Deterministic and race-free, replacing the old + // "first user to sign in becomes admin" heuristic. When empty, no admin is + // auto-assigned. + AdminEmail string + // GeminiAPIKey enables the Gemini moderation + synthesis passes. // When empty, consolidation falls back to non-AI aggregation. GeminiAPIKey string + // LogFormat selects the log handler: "text" (default) or "json". + LogFormat string + // Google OAuth credentials. GoogleClientID string GoogleClientSecret string @@ -46,7 +55,9 @@ func Load() (*Config, error) { DatabaseURL: getEnv("DATABASE_URL", "postgres://smart360:smart360@localhost:5432/smart360?sslmode=disable"), SessionSecret: os.Getenv("SESSION_SECRET"), AppURL: getEnv("APP_URL", "http://localhost:8080"), + AdminEmail: strings.TrimSpace(os.Getenv("ADMIN_EMAIL")), GeminiAPIKey: os.Getenv("GEMINI_API_KEY"), + LogFormat: getEnv("LOG_FORMAT", "text"), GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"), GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), GoogleRedirectURL: getEnv("GOOGLE_REDIRECT_URL", "http://localhost:8080/auth/callback"), @@ -68,7 +79,7 @@ func Load() (*Config, error) { // environment, skipping blanks, comments, and keys already set. It is a minimal // convenience loader for local development, not a full dotenv implementation. func loadDotEnv(path string) { - f, err := os.Open(path) + f, err := os.Open(path) // #nosec G304 -- path is the constant ".env" from Load, never user input if err != nil { return // no .env is fine } diff --git a/internal/db/migrations/0002_add_onboarded_at.sql b/internal/db/migrations/0002_add_onboarded_at.sql new file mode 100644 index 0000000..466a75a --- /dev/null +++ b/internal/db/migrations/0002_add_onboarded_at.sql @@ -0,0 +1,3 @@ +-- Track whether a user has completed the first-login onboarding tour. +-- NULL means "not yet onboarded" → the tour is shown on next login. +ALTER TABLE users ADD COLUMN onboarded_at timestamptz; diff --git a/internal/db/seed.go b/internal/db/seed.go index ec60965..440c14b 100644 --- a/internal/db/seed.go +++ b/internal/db/seed.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" "fmt" - "log" + "log/slog" "github.com/jackc/pgx/v5/pgxpool" @@ -22,7 +22,7 @@ func Seed(ctx context.Context, pool *pgxpool.Pool, devMode bool) error { return fmt.Errorf("seed template %q: %w", t.Slug, err) } } - log.Printf("seeded %d round template(s)", len(defaultTemplates)) + slog.Info("seeded round templates", "count", len(defaultTemplates)) return nil } diff --git a/internal/handlers/consolidation.go b/internal/handlers/consolidation.go index 8c25c31..596280b 100644 --- a/internal/handlers/consolidation.go +++ b/internal/handlers/consolidation.go @@ -175,8 +175,9 @@ func (h *Handlers) ConsolidationStream(w http.ResponseWriter, r *http.Request) { } h.audit(ctx, auditParams{Action: models.AuditConsolidationCreated, Actor: u, RoundID: id, Description: "Generated consolidation"}) + // No inline script (CSP script-src 'self'): app.js navigates on [data-redirect]. msgs <- sseMsg{"done", fmt.Sprintf( - `
Consolidation ready. View feedback
`, + `
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}}