diff --git a/DECISIONS.md b/DECISIONS.md index 5ace9b3..e7103da 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -82,3 +82,25 @@ Spec-silent choices, recorded as required by the build instructions. - **Mongo runs with `--wiredTigerCacheSizeGB 0.5`** in compose — the target host has 4 GB RAM; default cache (≈50% of RAM) caused a restart under the integration-test load. + +## Phase 4 + +- **CSP has no `unsafe-inline` for scripts from day one** (§12), so all JS is + external ES modules (`theme.js` loads synchronously in `
` to avoid a + theme flash; everything else is `type=module`). Inline event handlers are + never used. +- **`PATCH /api/v1/profile` is a partial update**: only fields present in the + body change. `version` is optional — when present the update is + version-checked (409 + reload toast on conflict); the nav theme toggle + omits it so flipping themes can't conflict with a profile edit. +- **Avatar uploads must sniff as `image/*`**; the rejected/replaced GridFS + files are deleted eagerly. Replacing an avatar removes the previous file. +- **File serving (`GET /files/{id}`)**: valid signed token (?st=) OR session. + With a session: avatars are visible to any logged-in user; other scopes + (chat/task) currently allow owner/admin/consultant and will be tightened as + those features land. +- **Pages that need login redirect to `/login?next=…`** (HTML UX), while API + routes return 401 JSON. Forced password change redirects all pages to + /change-password. +- **Pages for later phases render an "under construction" placeholder** so + role-based navigation is complete and clickable now. diff --git a/PROGRESS.md b/PROGRESS.md index 10d9fff..5172811 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -4,3 +4,4 @@ - Phase 1 (scaffold): repo layout, Makefile, .env loader + validated config, slog JSON, ULID pkg, trusted-proxy middleware, /healthz /readyz /metricsz, Dockerfile + compose (mongo+app, healthchecks, localhost-bound 8787), graceful shutdown — build/vet/tests green, smoke PASS. - Phase 2 (store layer): mongo-driver v2 connection with startup retry, §4.9 indexes idempotent, optimistic-concurrency UpdateVersioned (ErrVersionConflict/ErrNotFound), AES-256-GCM credential crypto, HMAC-signed file URL tokens (1h TTL), GridFS store with MIME sniffing + size cap + sha256, /readyz mongo check (verified 503 on stopped Mongo) — unit + integration tests green. - Phase 3 (auth): argon2id (PHC, spec params), login/register rate limiting, opaque Mongo sessions (30d sliding, TTL index), CSRF double-submit, bootstrap admin with forced password change, RBAC middleware + matrix tests, OIDC code flow with PKCE + email linking (verified-only), full integration suite incl. fake IdP — all green. +- Phase 4 (base UI shell): embedded templates + static assets (no build step), §10 beige/dark tokens with radius 2px, theme toggle persisted to localStorage + profile, login/register/change-password pages, profile page (avatar upload w/ MIME check, bio, contacts, arbitrary extra fields, version-conflict 409), role-based navigation, /files/{id} serving (session or signed token), CSP without inline scripts + security headers — unit + integration tests green, stack verified live. diff --git a/cmd/app/main.go b/cmd/app/main.go index 500f6d8..caea867 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -12,6 +12,7 @@ import ( "bountyboard/internal/auth" "bountyboard/internal/config" + "bountyboard/internal/files" httpx "bountyboard/internal/http" "bountyboard/internal/metrics" "bountyboard/internal/store" @@ -56,7 +57,7 @@ func run() error { } reg := metrics.NewRegistry() - srv := httpx.New(cfg, log, reg, st) + srv := httpx.New(cfg, log, reg, st, files.NewStore(st.DB, cfg.MaxUploadMB)) srv.AddReadinessCheck(httpx.ReadinessCheck{ Name: "mongo", Required: true, diff --git a/internal/http/auth_integration_test.go b/internal/http/auth_integration_test.go index 2da336e..60fddf5 100644 --- a/internal/http/auth_integration_test.go +++ b/internal/http/auth_integration_test.go @@ -19,6 +19,7 @@ import ( "bountyboard/internal/auth" "bountyboard/internal/config" "bountyboard/internal/domain" + "bountyboard/internal/files" "bountyboard/internal/metrics" "bountyboard/internal/store" "bountyboard/internal/testutil" @@ -47,7 +48,8 @@ func newAuthStack(t *testing.T, extraEnv map[string]string) (*httptest.Server, * if os.Getenv("TEST_LOG") == "1" { logOut = os.Stderr } - srv := New(cfg, slog.New(slog.NewTextHandler(logOut, nil)), metrics.NewRegistry(), st) + srv := New(cfg, slog.New(slog.NewTextHandler(logOut, nil)), metrics.NewRegistry(), st, + files.NewStore(db, cfg.MaxUploadMB)) ts := httptest.NewServer(srv.Handler()) t.Cleanup(ts.Close) return ts, srv, st, cfg diff --git a/internal/http/middleware.go b/internal/http/middleware.go index a3fd566..7e838d1 100644 --- a/internal/http/middleware.go +++ b/internal/http/middleware.go @@ -57,7 +57,23 @@ func (r *statusRecorder) Write(b []byte) (int, error) { func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter } func (s *Server) withMiddleware(next http.Handler) http.Handler { - return s.recoverPanic(s.assignRequestID(s.resolveClientInfo(s.accessLog(next)))) + return s.recoverPanic(s.assignRequestID(s.resolveClientInfo(s.securityHeaders(s.accessLog(next))))) +} + +// securityHeaders applies the §12 hardening headers. CSP allows no inline +// scripts — all JS ships as external modules. +func (s *Server) securityHeaders(next http.Handler) http.Handler { + const csp = "default-src 'self'; script-src 'self'; style-src 'self'; " + + "img-src 'self' data:; connect-src 'self'; font-src 'self'; " + + "frame-ancestors 'none'; base-uri 'self'; form-action 'self'" + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Content-Security-Policy", csp) + h.Set("X-Frame-Options", "DENY") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "strict-origin-when-cross-origin") + next.ServeHTTP(w, r) + }) } // recoverPanic guarantees no panic escapes a request path: it logs the stack diff --git a/internal/http/profile.go b/internal/http/profile.go new file mode 100644 index 0000000..b187ed5 --- /dev/null +++ b/internal/http/profile.go @@ -0,0 +1,226 @@ +package httpx + +import ( + "errors" + "io" + "net/http" + "strconv" + "strings" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + + "bountyboard/internal/domain" + "bountyboard/internal/files" + "bountyboard/internal/store" +) + +func (s *Server) routesProfile(mux *http.ServeMux) { + mux.Handle("GET /api/v1/profile", s.requireAuth(http.HandlerFunc(s.handleGetProfile))) + mux.Handle("PATCH /api/v1/profile", s.authed(s.handlePatchProfile)) + mux.Handle("POST /api/v1/profile/avatar", s.authed(s.handleAvatarUpload)) + mux.HandleFunc("GET /files/{id}", s.handleGetFile) +} + +func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"user": CurrentUser(r.Context())}) +} + +// handlePatchProfile applies a partial profile update. Fields absent from +// the body are untouched. When "version" is supplied the update is +// optimistic-concurrency checked (409 on conflict); theme-only updates from +// the nav toggle omit it. +func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) { + var req struct { + Version *int64 `json:"version"` + Name *string `json:"name"` + Bio *string `json:"bio"` + Contact *domain.Contact `json:"contact"` + Extra *map[string]any `json:"extra"` + Settings *struct { + Theme *string `json:"theme"` + Notifications *domain.NotificationPrefs `json:"notifications"` + } `json:"settings"` + } + if !decodeJSON(w, r, &req) { + return + } + + set := bson.M{} + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name == "" { + writeError(w, http.StatusBadRequest, "invalid_name", "name cannot be empty") + return + } + set["name"] = name + } + if req.Bio != nil { + set["bio"] = *req.Bio + } + if req.Contact != nil { + if req.Contact.Links == nil { + req.Contact.Links = []string{} + } + set["contact"] = *req.Contact + } + if req.Extra != nil { + set["extra"] = *req.Extra + } + if req.Settings != nil { + if req.Settings.Theme != nil { + theme := *req.Settings.Theme + if theme != "light" && theme != "dark" { + writeError(w, http.StatusBadRequest, "invalid_theme", "theme must be light or dark") + return + } + set["settings.theme"] = theme + } + if req.Settings.Notifications != nil { + set["settings.notifications"] = *req.Settings.Notifications + } + } + if len(set) == 0 { + writeError(w, http.StatusBadRequest, "empty_update", "no recognized fields to update") + return + } + + u := CurrentUser(r.Context()) + version := u.Version + if req.Version != nil { + version = *req.Version + } + err := store.UpdateVersioned(r.Context(), s.store.DB.Collection("users"), u.ID, version, bson.M{"$set": set}) + switch { + case errors.Is(err, store.ErrVersionConflict): + writeError(w, http.StatusConflict, "conflict", "profile was modified elsewhere; reload and retry") + return + case err != nil: + s.internalError(w, r, "update profile", err) + return + } + fresh, err := s.store.UserByID(r.Context(), u.ID) + if err != nil { + s.internalError(w, r, "reload profile", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"user": fresh}) +} + +func (s *Server) handleAvatarUpload(w http.ResponseWriter, r *http.Request) { + u := CurrentUser(r.Context()) + r.Body = http.MaxBytesReader(w, r.Body, int64(s.cfg.MaxUploadMB)<<20+1<<20) + file, header, err := r.FormFile("file") + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "multipart field 'file' is required") + return + } + defer file.Close() + + meta, err := s.files.Save(r.Context(), files.ScopeAvatar, u.ID, header.Filename, + header.Header.Get("Content-Type"), file) + if err != nil { + if errors.Is(err, files.ErrTooLarge) { + writeError(w, http.StatusRequestEntityTooLarge, "too_large", err.Error()) + return + } + s.internalError(w, r, "save avatar", err) + return + } + if !strings.HasPrefix(meta.MimeType, "image/") { + if delErr := s.files.Delete(r.Context(), meta.ID); delErr != nil { + s.log.Warn("delete rejected avatar", "err", delErr) + } + writeError(w, http.StatusBadRequest, "not_an_image", "avatar must be an image file") + return + } + + old := u.AvatarFileID + err = store.UpdateVersioned(r.Context(), s.store.DB.Collection("users"), u.ID, u.Version, + bson.M{"$set": bson.M{"avatarFileId": meta.ID}}) + if err != nil { + if delErr := s.files.Delete(r.Context(), meta.ID); delErr != nil { + s.log.Warn("delete orphaned avatar", "err", delErr) + } + if errors.Is(err, store.ErrVersionConflict) { + writeError(w, http.StatusConflict, "conflict", "profile was modified elsewhere; reload and retry") + return + } + s.internalError(w, r, "update avatar", err) + return + } + if old != "" { + if delErr := s.files.Delete(r.Context(), old); delErr != nil && !errors.Is(delErr, files.ErrNotFound) { + s.log.Warn("delete previous avatar", "err", delErr) + } + } + writeJSON(w, http.StatusOK, map[string]string{"fileId": meta.ID}) +} + +// handleGetFile serves stored files (§4.7): a valid session OR a valid +// signed token (?st=, §5.1) grants access. Scope rules: avatars are visible +// to any authenticated user; chat/task files additionally require ownership +// until their features land (tightened per-scope in later phases). +func (s *Server) handleGetFile(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + + signedOK := false + if st := r.URL.Query().Get("st"); st != "" { + signedOK = files.VerifyToken([]byte(s.cfg.SessionSecret), id, st, time.Now()) + } + var user *domain.User + if !signedOK { + user = s.pageUser(r) + if user == nil { + writeError(w, http.StatusUnauthorized, "unauthenticated", "session or signed token required") + return + } + } + + rc, meta, err := s.files.Open(r.Context(), id) + if err != nil { + if errors.Is(err, files.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "file not found") + return + } + s.internalError(w, r, "open file", err) + return + } + defer rc.Close() + + if !signedOK { + allowed := false + switch meta.Scope { + case files.ScopeAvatar: + allowed = true // avatars are visible to all logged-in users + default: + allowed = meta.OwnerID == user.ID || user.Roles.Admin || + user.Roles.Consultant // scoped tighter as chat/task features land + } + if !allowed { + writeError(w, http.StatusForbidden, "forbidden", "no access to this file") + return + } + } + + w.Header().Set("Content-Type", meta.MimeType) + w.Header().Set("X-Content-Type-Options", "nosniff") + if meta.SHA256 != "" { + etag := `"` + meta.SHA256 + `"` + w.Header().Set("ETag", etag) + if r.Header.Get("If-None-Match") == etag { + w.WriteHeader(http.StatusNotModified) + return + } + } + disposition := "attachment" + if strings.HasPrefix(meta.MimeType, "image/") || meta.MimeType == "application/pdf" || + strings.HasPrefix(meta.MimeType, "text/") { + disposition = "inline" + } + w.Header().Set("Content-Disposition", disposition+`; filename="`+strings.ReplaceAll(meta.Name, `"`, "")+`"`) + w.Header().Set("Content-Length", strconv.FormatInt(meta.Size, 10)) + if _, err := io.Copy(w, rc); err != nil { + s.log.Warn("stream file", "id", id, "err", err) + } +} diff --git a/internal/http/profile_integration_test.go b/internal/http/profile_integration_test.go new file mode 100644 index 0000000..2cf4ea4 --- /dev/null +++ b/internal/http/profile_integration_test.go @@ -0,0 +1,286 @@ +//go:build integration + +package httpx + +import ( + "bytes" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "strings" + "testing" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + + "bountyboard/internal/domain" + "bountyboard/internal/files" +) + +// tiny valid 1x1 PNG +var pngBytes = []byte{ + 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, + 0, 0, 0, 0x0D, 'I', 'H', 'D', 'R', 0, 0, 0, 1, 0, 0, 0, 1, + 8, 6, 0, 0, 0, 0x1F, 0x15, 0xC4, 0x89, + 0, 0, 0, 0x0A, 'I', 'D', 'A', 'T', 0x78, 0x9C, 0x63, 0, 1, 0, 0, 5, 0, 1, + 0x0D, 0x0A, 0x2D, 0xB4, 0, 0, 0, 0, 'I', 'E', 'N', 'D', 0xAE, 0x42, 0x60, 0x82, +} + +func registerUser(t *testing.T, ts string, c *http.Client, email, name string) domain.User { + t.Helper() + resp := postJSON(t, c, ts+"/api/v1/auth/register", + map[string]string{"email": email, "name": name, "password": "password123"}, "") + if resp.StatusCode != http.StatusCreated { + t.Fatalf("register %s: %d", email, resp.StatusCode) + } + var out struct { + User domain.User `json:"user"` + } + bodyJSON(t, resp, &out) + return out.User +} + +func patchJSON(t *testing.T, c *http.Client, rawURL string, body any, csrf string) *http.Response { + t.Helper() + b, _ := json.Marshal(body) + req, _ := http.NewRequest(http.MethodPatch, rawURL, bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set(csrfHeader, csrf) + resp, err := c.Do(req) + if err != nil { + t.Fatal(err) + } + return resp +} + +func TestProfilePatchAndThemePersistence(t *testing.T) { + ts, _, _, _ := newAuthStack(t, nil) + c := newClient(t) + u := registerUser(t, ts.URL, c, "p@example.com", "P User") + csrf := csrfFrom(t, c, ts.URL) + + resp := patchJSON(t, c, ts.URL+"/api/v1/profile", map[string]any{ + "version": u.Version, + "name": "Updated Name", + "bio": "I write Go.", + "contact": map[string]any{"phone": "+1 555 0100", "location": "Berlin", "links": []string{"https://example.com"}}, + "extra": map[string]any{"GitHub": "puser", "Timezone": "CET"}, + "settings": map[string]any{ + "theme": "dark", + }, + }, csrf) + if resp.StatusCode != http.StatusOK { + t.Fatalf("patch: %d", resp.StatusCode) + } + var out struct { + User domain.User `json:"user"` + } + bodyJSON(t, resp, &out) + if out.User.Name != "Updated Name" || out.User.Settings.Theme != "dark" || + out.User.Contact.Location != "Berlin" || out.User.Extra["GitHub"] != "puser" { + t.Fatalf("patched user wrong: %+v", out.User) + } + if out.User.Version != u.Version+1 { + t.Errorf("version = %d, want %d", out.User.Version, u.Version+1) + } + + // stale version → 409 + resp = patchJSON(t, c, ts.URL+"/api/v1/profile", map[string]any{ + "version": u.Version, "name": "Stale Write", + }, csrf) + if resp.StatusCode != http.StatusConflict { + t.Fatalf("stale patch: %d, want 409", resp.StatusCode) + } + resp.Body.Close() + + // theme-only update without version (nav toggle path) + resp = patchJSON(t, c, ts.URL+"/api/v1/profile", map[string]any{ + "settings": map[string]any{"theme": "light"}, + }, csrf) + if resp.StatusCode != http.StatusOK { + t.Fatalf("theme toggle patch: %d", resp.StatusCode) + } + bodyJSON(t, resp, &out) + if out.User.Settings.Theme != "light" { + t.Errorf("theme = %q", out.User.Settings.Theme) + } + + // invalid theme rejected + resp = patchJSON(t, c, ts.URL+"/api/v1/profile", map[string]any{ + "settings": map[string]any{"theme": "neon"}, + }, csrf) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("invalid theme: %d", resp.StatusCode) + } + resp.Body.Close() +} + +func uploadAvatar(t *testing.T, c *http.Client, ts, csrf, filename string, content []byte) *http.Response { + t.Helper() + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, err := mw.CreateFormFile("file", filename) + if err != nil { + t.Fatal(err) + } + if _, err := fw.Write(content); err != nil { + t.Fatal(err) + } + mw.Close() + req, _ := http.NewRequest(http.MethodPost, ts+"/api/v1/profile/avatar", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set(csrfHeader, csrf) + resp, err := c.Do(req) + if err != nil { + t.Fatal(err) + } + return resp +} + +func TestAvatarUploadAndFileServing(t *testing.T) { + ts, _, _, cfg := newAuthStack(t, nil) + c := newClient(t) + registerUser(t, ts.URL, c, "av@example.com", "Av User") + csrf := csrfFrom(t, c, ts.URL) + + // non-image rejected + resp := uploadAvatar(t, c, ts.URL, csrf, "notes.txt", []byte("just text, definitely not an image")) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("text avatar: %d, want 400", resp.StatusCode) + } + resp.Body.Close() + + // PNG accepted + resp = uploadAvatar(t, c, ts.URL, csrf, "me.png", pngBytes) + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("png avatar: %d %s", resp.StatusCode, b) + } + var up struct { + FileID string `json:"fileId"` + } + bodyJSON(t, resp, &up) + + // served with session + fResp, err := c.Get(ts.URL + "/files/" + up.FileID) + if err != nil { + t.Fatal(err) + } + got, _ := io.ReadAll(fResp.Body) + fResp.Body.Close() + if fResp.StatusCode != http.StatusOK || !bytes.Equal(got, pngBytes) { + t.Fatalf("file fetch: %d, %d bytes", fResp.StatusCode, len(got)) + } + if ct := fResp.Header.Get("Content-Type"); ct != "image/png" { + t.Errorf("content type %q", ct) + } + + // anonymous fetch rejected + aResp, err := http.Get(ts.URL + "/files/" + up.FileID) + if err != nil { + t.Fatal(err) + } + aResp.Body.Close() + if aResp.StatusCode != http.StatusUnauthorized { + t.Fatalf("anonymous file fetch: %d, want 401", aResp.StatusCode) + } + + // signed token works without a session and expires (§5.1 acceptance) + goodURL := files.SignedURL(ts.URL, []byte(cfg.SessionSecret), up.FileID, time.Now().Add(time.Hour)) + gResp, err := http.Get(goodURL) + if err != nil { + t.Fatal(err) + } + gResp.Body.Close() + if gResp.StatusCode != http.StatusOK { + t.Fatalf("signed fetch: %d", gResp.StatusCode) + } + expiredURL := files.SignedURL(ts.URL, []byte(cfg.SessionSecret), up.FileID, time.Now().Add(-time.Minute)) + eResp, err := http.Get(expiredURL) + if err != nil { + t.Fatal(err) + } + eResp.Body.Close() + if eResp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expired signed fetch: %d, want 401", eResp.StatusCode) + } + + // replacing the avatar deletes the old file + resp = uploadAvatar(t, c, ts.URL, csrf, "me2.png", pngBytes) + var up2 struct { + FileID string `json:"fileId"` + } + bodyJSON(t, resp, &up2) + oldResp, err := c.Get(ts.URL + "/files/" + up.FileID) + if err != nil { + t.Fatal(err) + } + oldResp.Body.Close() + if oldResp.StatusCode != http.StatusNotFound { + t.Fatalf("old avatar after replace: %d, want 404", oldResp.StatusCode) + } +} + +func TestNavigationByRole(t *testing.T) { + ts, _, st, _ := newAuthStack(t, nil) + + c := newClient(t) + u := registerUser(t, ts.URL, c, "nav@example.com", "Nav User") + + fetchHome := func() string { + resp, err := c.Get(ts.URL + "/") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("home: %d", resp.StatusCode) + } + b, _ := io.ReadAll(resp.Body) + return string(b) + } + + // developer: board links, no admin/consultant links + html := fetchHome() + if !strings.Contains(html, `href="/board"`) { + t.Error("developer nav missing bounty board") + } + for _, forbidden := range []string{`href="/admin"`, `href="/consultant/board"`} { + if strings.Contains(html, forbidden) { + t.Errorf("developer nav leaks %s", forbidden) + } + } + + // grant consultant+admin directly in the store + if _, err := st.DB.Collection("users").UpdateOne(t.Context(), + bson.M{"_id": u.ID}, + bson.M{"$set": bson.M{"roles.admin": true, "roles.consultant": true}}); err != nil { + t.Fatal(err) + } + html = fetchHome() + for _, want := range []string{`href="/admin"`, `href="/consultant/board"`, `href="/consultant/reviews"`} { + if !strings.Contains(html, want) { + t.Errorf("admin+consultant nav missing %s", want) + } + } +} + +func TestForcedPasswordChangeRedirectsPages(t *testing.T) { + ts, _, st, _ := newAuthStack(t, nil) + c := newClient(t) + u := registerUser(t, ts.URL, c, "fc@example.com", "FC") + + if _, err := st.DB.Collection("users").UpdateOne(t.Context(), + bson.M{"_id": u.ID}, bson.M{"$set": bson.M{"auth.local.mustChange": true}}); err != nil { + t.Fatal(err) + } + resp, err := c.Get(ts.URL + "/profile") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != "/change-password" { + t.Fatalf("page under mustChange: %d → %q", resp.StatusCode, resp.Header.Get("Location")) + } +} diff --git a/internal/http/server.go b/internal/http/server.go index 413ea62..faa64f5 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -4,12 +4,14 @@ import ( "context" "errors" "fmt" + "html/template" "log/slog" "net/http" "time" "bountyboard/internal/auth" "bountyboard/internal/config" + "bountyboard/internal/files" "bountyboard/internal/metrics" "bountyboard/internal/store" ) @@ -22,6 +24,8 @@ type Server struct { log *slog.Logger metrics *metrics.Registry store *store.Store + files *files.Store + templates map[string]*template.Template oidc *auth.OIDCClient loginLimiter *auth.RateLimiter checks []ReadinessCheck @@ -29,12 +33,20 @@ type Server struct { httpSrv *http.Server } -func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.Store) *Server { +func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.Store, fs *files.Store) *Server { + templates, err := parseTemplates() + if err != nil { + // Templates are embedded; failure is a build defect caught by any + // test or first boot, never a runtime condition. + panic(fmt.Sprintf("parse templates: %v", err)) + } s := &Server{ cfg: cfg, log: log, metrics: reg, store: st, + files: fs, + templates: templates, oidc: auth.NewOIDCClient(cfg, log), loginLimiter: auth.NewRateLimiter(10, 15*time.Minute), startedAt: time.Now(), @@ -45,6 +57,8 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store. mux.HandleFunc("GET /readyz", s.handleReadyz) mux.HandleFunc("GET /metricsz", s.handleMetricsz) s.routesAuth(mux) + s.routesProfile(mux) + s.routesWeb(mux) s.httpSrv = &http.Server{ Addr: fmt.Sprintf(":%d", cfg.AppPort), diff --git a/internal/http/server_test.go b/internal/http/server_test.go index b40c8fe..52c1e92 100644 --- a/internal/http/server_test.go +++ b/internal/http/server_test.go @@ -25,7 +25,7 @@ func newTestServer(t *testing.T) *Server { t.Fatalf("config: %v", err) } log := slog.New(slog.NewTextHandler(io.Discard, nil)) - return New(cfg, log, metrics.NewRegistry(), nil) + return New(cfg, log, metrics.NewRegistry(), nil, nil) } func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder { diff --git a/internal/http/web.go b/internal/http/web.go new file mode 100644 index 0000000..b1ac78b --- /dev/null +++ b/internal/http/web.go @@ -0,0 +1,215 @@ +package httpx + +import ( + "fmt" + "html/template" + "io/fs" + "net/http" + "net/url" + "strings" + + "bountyboard/internal/domain" + "bountyboard/web" +) + +// pageData is the payload every template receives. +type pageData struct { + Title string + Active string // nav highlight key + User *domain.User + DefaultTheme string + Narrow bool + OIDCEnabled bool + Error string + HasLocalAuth bool + Scripts []string + Data map[string]any // page-specific extras +} + +var templateFuncs = template.FuncMap{ + "initials": func(name string) string { + parts := strings.Fields(name) + switch { + case len(parts) >= 2: + return strings.ToUpper(string([]rune(parts[0])[:1]) + string([]rune(parts[1])[:1])) + case len(parts) == 1 && len(parts[0]) > 0: + return strings.ToUpper(string([]rune(parts[0])[:1])) + default: + return "?" + } + }, +} + +// parseTemplates builds one template set per page (layout + page content). +func parseTemplates() (map[string]*template.Template, error) { + pages, err := fs.Glob(web.FS, "templates/*.html") + if err != nil { + return nil, err + } + out := make(map[string]*template.Template) + for _, page := range pages { + name := strings.TrimPrefix(page, "templates/") + if name == "layout.html" { + continue + } + t, err := template.New(name).Funcs(templateFuncs). + ParseFS(web.FS, "templates/layout.html", page) + if err != nil { + return nil, fmt.Errorf("parse %s: %w", page, err) + } + out[name] = t + } + return out, nil +} + +func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, data *pageData) { + t, ok := s.templates[page] + if !ok { + s.internalError(w, r, "render", fmt.Errorf("unknown template %q", page)) + return + } + if data == nil { + data = &pageData{} + } + if data.DefaultTheme == "" { + data.DefaultTheme = "light" + if data.User != nil && data.User.Settings.Theme != "" { + data.DefaultTheme = data.User.Settings.Theme + } + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := t.ExecuteTemplate(w, "layout", data); err != nil { + s.log.Error("execute template", "page", page, "err", err) + } +} + +// pageUser resolves the session for an HTML page; returns nil when not +// logged in (no error response — pages redirect instead). +func (s *Server) pageUser(r *http.Request) *domain.User { + c, err := r.Cookie(sessionCookie) + if err != nil || c.Value == "" { + return nil + } + sess, err := s.store.SessionByToken(r.Context(), c.Value) + if err != nil { + return nil + } + u, err := s.store.UserByID(r.Context(), sess.UserID) + if err != nil || u.Disabled { + return nil + } + return u +} + +// page wraps an HTML handler that requires login: redirects anonymous +// visitors to /login?next=… and forces pending password changes through +// /change-password. +func (s *Server) page(h func(http.ResponseWriter, *http.Request, *domain.User)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + u := s.pageUser(r) + if u == nil { + http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound) + return + } + if u.MustChangePassword() && r.URL.Path != "/change-password" { + http.Redirect(w, r, "/change-password", http.StatusFound) + return + } + h(w, r, u) + } +} + +func (s *Server) routesWeb(mux *http.ServeMux) { + staticFS, err := fs.Sub(web.FS, "static") + if err != nil { + panic(err) // embed layout is fixed at compile time + } + mux.Handle("GET /static/", http.StripPrefix("/static/", + cacheControl(http.FileServerFS(staticFS), "public, max-age=3600"))) + + mux.HandleFunc("GET /{$}", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) { + s.render(w, r, "home.html", &pageData{Title: "Home", User: u}) + })) + + mux.HandleFunc("GET /login", func(w http.ResponseWriter, r *http.Request) { + if s.pageUser(r) != nil { + http.Redirect(w, r, "/", http.StatusFound) + return + } + s.render(w, r, "login.html", &pageData{ + Title: "Log in", Narrow: true, + OIDCEnabled: s.cfg.OIDC.Enabled(), + Error: loginErrorMessage(r.URL.Query().Get("error")), + Scripts: []string{"/static/js/login.js"}, + }) + }) + + mux.HandleFunc("GET /register", func(w http.ResponseWriter, r *http.Request) { + if s.pageUser(r) != nil { + http.Redirect(w, r, "/", http.StatusFound) + return + } + s.render(w, r, "register.html", &pageData{ + Title: "Create account", Narrow: true, + Scripts: []string{"/static/js/register.js"}, + }) + }) + + mux.HandleFunc("GET /change-password", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) { + s.render(w, r, "change-password.html", &pageData{ + Title: "Change password", User: u, Narrow: true, + Scripts: []string{"/static/js/change-password.js"}, + }) + })) + + mux.HandleFunc("GET /profile", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) { + s.render(w, r, "profile.html", &pageData{ + Title: "Profile", User: u, + HasLocalAuth: u.Auth.Local != nil, + Scripts: []string{"/static/js/profile.js"}, + }) + })) + + // Placeholders for areas built in later phases; replaced as they land. + placeholders := map[string]string{ + "/board": "Bounty Board", + "/my-tasks": "My Tasks", + "/consultant/board": "Atomization Board", + "/consultant/reviews": "Review Queue", + "/consultant/pool": "Developer Pool", + "/admin": "Administration", + "/messages": "Messages", + "/metrics": "Metrics", + } + for path, title := range placeholders { + mux.HandleFunc("GET "+path, s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) { + s.render(w, r, "placeholder.html", &pageData{Title: title, User: u}) + })) + } +} + +func loginErrorMessage(code string) string { + switch code { + case "": + return "" + case "oidc_state_mismatch", "oidc_failed": + return "SSO sign-in failed. Please try again." + case "oidc_denied": + return "SSO sign-in was cancelled." + case "oidc_email_unverified": + return "Your SSO email address is not verified; ask your identity provider administrator." + case "oidc_no_email": + return "Your SSO identity has no email address; one is required." + case "account_disabled": + return "This account is disabled." + default: + return "Sign-in failed." + } +} + +func cacheControl(next http.Handler, value string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", value) + next.ServeHTTP(w, r) + }) +} diff --git a/internal/http/web_test.go b/internal/http/web_test.go new file mode 100644 index 0000000..bacbd8d --- /dev/null +++ b/internal/http/web_test.go @@ -0,0 +1,109 @@ +package httpx + +import ( + "net/http" + "strings" + "testing" +) + +func TestLoginPageRenders(t *testing.T) { + s := newTestServer(t) + rec := get(t, s.Handler(), "/login") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{"login-form", `id="email"`, `id="password"`, "/static/js/login.js"} { + if !strings.Contains(body, want) { + t.Errorf("login page missing %q", want) + } + } + // OIDC is not configured → no SSO button + if strings.Contains(body, "Continue with SSO") { + t.Error("SSO button shown although OIDC is disabled") + } + if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "script-src 'self'") { + t.Errorf("CSP missing or weak: %q", csp) + } + if rec.Header().Get("X-Frame-Options") != "DENY" { + t.Error("X-Frame-Options missing") + } +} + +func TestRegisterPageRenders(t *testing.T) { + s := newTestServer(t) + rec := get(t, s.Handler(), "/register") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "register-form") { + t.Error("register form missing") + } +} + +func TestAnonymousPageRedirectsToLogin(t *testing.T) { + s := newTestServer(t) + for _, path := range []string{"/", "/profile", "/board", "/admin", "/messages"} { + rec := get(t, s.Handler(), path) + if rec.Code != http.StatusFound { + t.Errorf("%s: status = %d, want 302", path, rec.Code) + continue + } + loc := rec.Header().Get("Location") + if !strings.HasPrefix(loc, "/login?next=") { + t.Errorf("%s: redirect to %q", path, loc) + } + } +} + +func TestStaticAssetsServed(t *testing.T) { + s := newTestServer(t) + tests := map[string]string{ + "/static/css/app.css": "--bg:#f3ead9", // beige light token from §10 + "/static/js/theme.js": "data-default-theme", + "/static/js/api.js": "X-CSRF-Token", + "/static/js/login.js": "auth/login", + "/static/js/nav.js": "nav-theme", + } + for path, want := range tests { + rec := get(t, s.Handler(), path) + if rec.Code != http.StatusOK { + t.Errorf("%s: status = %d", path, rec.Code) + continue + } + if !strings.Contains(rec.Body.String(), want) { + t.Errorf("%s: missing %q", path, want) + } + } +} + +func TestThemeTokensPresent(t *testing.T) { + s := newTestServer(t) + rec := get(t, s.Handler(), "/static/css/app.css") + css := rec.Body.String() + // spot-check both §10 palettes and the square-edge radius + for _, tok := range []string{ + "--bg:#f3ead9", "--accent:#8a5a2b", // light + "--bg:#191714", "--accent:#caa15e", // dark + "--radius:2px", + } { + if !strings.Contains(css, tok) { + t.Errorf("css missing design token %q", tok) + } + } +} + +func TestInitialsFunc(t *testing.T) { + tests := []struct{ in, want string }{ + {"Jane Doe", "JD"}, + {"single", "S"}, + {"", "?"}, + {" spaced out ", "SO"}, + } + f := templateFuncs["initials"].(func(string) string) + for _, tt := range tests { + if got := f(tt.in); got != tt.want { + t.Errorf("initials(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} diff --git a/web/static/css/app.css b/web/static/css/app.css new file mode 100644 index 0000000..df99502 --- /dev/null +++ b/web/static/css/app.css @@ -0,0 +1,161 @@ +/* Bounty Board — hand-written CSS, no framework (§2.2). + Theming via CSS custom properties on . */ + +:root[data-theme=light] { + --bg:#f3ead9; --surface:#faf5ea; --surface2:#efe5d0; --border:#d8cbb0; + --text:#2b2620; --muted:#6f6353; --accent:#8a5a2b; --accent-contrast:#fff; + --ok:#3c6e47; --warn:#a06a1f; --err:#9c3a2e; --radius:2px; +} +:root[data-theme=dark] { + --bg:#191714; --surface:#221f1b; --surface2:#2b2722; --border:#3a342c; + --text:#ece5d8; --muted:#a59a87; --accent:#caa15e; --accent-contrast:#1a160f; + --ok:#7fb78a; --warn:#d9a44a; --err:#d97b6c; --radius:2px; +} + +/* ---- base ---- */ +* { box-sizing: border-box; } +html { font-size: 16px; } +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + line-height: 1.5; +} +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +h1, h2, h3 { line-height: 1.25; margin: 0 0 16px; } +h1 { font-size: 1.5rem; } +h2 { font-size: 1.2rem; } +h3 { font-size: 1rem; } +code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* ---- layout ---- */ +.container { max-width: 1100px; margin: 0 auto; padding: 24px 16px; } +.narrow { max-width: 460px; } + +.topnav { + display: flex; align-items: center; gap: 16px; + background: var(--surface); + border-bottom: 1px solid var(--border); + padding: 8px 16px; +} +.topnav .brand { font-weight: 700; color: var(--text); } +.topnav .links { display: flex; gap: 8px; flex: 1; flex-wrap: wrap; } +.topnav .links a { + color: var(--muted); padding: 6px 10px; border-radius: var(--radius); +} +.topnav .links a:hover { color: var(--text); background: var(--surface2); text-decoration: none; } +.topnav .links a[aria-current=page] { color: var(--text); background: var(--surface2); } +.topnav .right { display: flex; align-items: center; gap: 8px; } + +/* ---- components ---- */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 24px; +} +.card + .card { margin-top: 16px; } + +.btn { + display: inline-flex; align-items: center; gap: 8px; + font: inherit; cursor: pointer; + padding: 8px 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface2); + color: var(--text); +} +.btn:hover { filter: brightness(0.97); } +.btn.primary { + background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); +} +.btn.danger { background: var(--err); color: #fff; border-color: var(--err); } +.btn.ghost { background: transparent; } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } +.btn.small { padding: 4px 8px; font-size: 0.875rem; } + +label { display: block; font-weight: 600; margin-bottom: 4px; } +.hint { color: var(--muted); font-size: 0.875rem; margin: 4px 0 0; } +input[type=text], input[type=email], input[type=password], input[type=number], +select, textarea { + width: 100%; + font: inherit; color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 8px; +} +textarea { resize: vertical; min-height: 80px; } +.field { margin-bottom: 16px; } +.row { display: flex; gap: 16px; } +.row > * { flex: 1; } + +.error-box, .ok-box { + border: 1px solid var(--err); color: var(--err); + border-radius: var(--radius); + padding: 8px 12px; margin-bottom: 16px; +} +.ok-box { border-color: var(--ok); color: var(--ok); } +.error-box:empty, .ok-box:empty { display: none; } + +.badge { + display: inline-block; font-size: 0.75rem; font-weight: 600; + padding: 2px 8px; border-radius: var(--radius); + background: var(--surface2); color: var(--muted); + border: 1px solid var(--border); +} +.badge.accent { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); } + +.avatar { + width: 32px; height: 32px; border-radius: var(--radius); + background: var(--accent); color: var(--accent-contrast); + display: inline-flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 0.875rem; + object-fit: cover; overflow: hidden; +} +.avatar.large { width: 96px; height: 96px; font-size: 2rem; } +img.avatar { background: var(--surface2); } + +table.list { width: 100%; border-collapse: collapse; } +table.list th, table.list td { + text-align: left; padding: 8px; + border-bottom: 1px solid var(--border); +} +table.list th { color: var(--muted); font-size: 0.875rem; } + +.grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } + +.muted { color: var(--muted); } +.spread { display: flex; justify-content: space-between; align-items: center; gap: 16px; } +.stack > * + * { margin-top: 16px; } +.mt { margin-top: 16px; } + +.kv-row { display: flex; gap: 8px; margin-bottom: 8px; } +.kv-row input { flex: 1; } + +/* toast notifications */ +#toasts { + position: fixed; bottom: 16px; right: 16px; z-index: 100; + display: flex; flex-direction: column; gap: 8px; max-width: 360px; +} +.toast { + background: var(--surface); border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: var(--radius); + padding: 12px 16px; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); +} +.toast.err { border-left-color: var(--err); } +.toast.ok { border-left-color: var(--ok); } + +@media (max-width: 640px) { + .row { flex-direction: column; gap: 0; } + .topnav { flex-wrap: wrap; } +} diff --git a/web/static/js/api.js b/web/static/js/api.js new file mode 100644 index 0000000..d7b5153 --- /dev/null +++ b/web/static/js/api.js @@ -0,0 +1,52 @@ +// Tiny API client: JSON + CSRF double-submit header + error envelope. +export function csrfToken() { + const m = document.cookie.match(/(?:^|;\s*)bb_csrf=([^;]+)/); + return m ? decodeURIComponent(m[1]) : ''; +} + +export class ApiError extends Error { + constructor(status, code, message) { + super(message); + this.status = status; + this.code = code; + } +} + +export async function api(method, path, body) { + const opts = { method, headers: {} }; + if (method !== 'GET' && method !== 'HEAD') { + opts.headers['X-CSRF-Token'] = csrfToken(); + } + if (body !== undefined) { + if (body instanceof FormData) { + opts.body = body; + } else { + opts.headers['Content-Type'] = 'application/json'; + opts.body = JSON.stringify(body); + } + } + const resp = await fetch(path, opts); + if (resp.status === 204) return null; + let data = null; + try { data = await resp.json(); } catch (e) { /* non-JSON */ } + if (!resp.ok) { + const err = (data && data.error) || {}; + throw new ApiError(resp.status, err.code || 'unknown', err.message || resp.statusText); + } + return data; +} + +export function toast(message, kind) { + let host = document.getElementById('toasts'); + if (!host) { + host = document.createElement('div'); + host.id = 'toasts'; + document.body.appendChild(host); + } + const t = document.createElement('div'); + t.className = 'toast' + (kind ? ' ' + kind : ''); + t.setAttribute('role', 'status'); + t.textContent = message; + host.appendChild(t); + setTimeout(() => t.remove(), 6000); +} diff --git a/web/static/js/change-password.js b/web/static/js/change-password.js new file mode 100644 index 0000000..4307ab3 --- /dev/null +++ b/web/static/js/change-password.js @@ -0,0 +1,23 @@ +import { api } from '/static/js/api.js'; + +const form = document.getElementById('change-password-form'); +const errorBox = document.getElementById('error'); + +form.addEventListener('submit', async (e) => { + e.preventDefault(); + errorBox.textContent = ''; + const newPw = document.getElementById('new').value; + if (newPw !== document.getElementById('confirm').value) { + errorBox.textContent = 'New passwords do not match.'; + return; + } + try { + await api('POST', '/api/v1/auth/change-password', { + currentPassword: document.getElementById('current').value, + newPassword: newPw, + }); + window.location.href = '/'; + } catch (err) { + errorBox.textContent = err.message; + } +}); diff --git a/web/static/js/login.js b/web/static/js/login.js new file mode 100644 index 0000000..bf9ba34 --- /dev/null +++ b/web/static/js/login.js @@ -0,0 +1,23 @@ +import { api } from '/static/js/api.js'; + +const form = document.getElementById('login-form'); +const errorBox = document.getElementById('error'); + +function safeNext() { + const next = new URLSearchParams(window.location.search).get('next') || '/'; + return next.startsWith('/') && !next.startsWith('//') ? next : '/'; +} + +form.addEventListener('submit', async (e) => { + e.preventDefault(); + errorBox.textContent = ''; + try { + const res = await api('POST', '/api/v1/auth/login', { + email: document.getElementById('email').value.trim(), + password: document.getElementById('password').value, + }); + window.location.href = res.mustChangePassword ? '/change-password' : safeNext(); + } catch (err) { + errorBox.textContent = err.message; + } +}); diff --git a/web/static/js/nav.js b/web/static/js/nav.js new file mode 100644 index 0000000..cac1191 --- /dev/null +++ b/web/static/js/nav.js @@ -0,0 +1,30 @@ +// Top navigation behaviors: logout + theme toggle (persisted to +// localStorage immediately and to the profile when logged in). +import { api, toast } from '/static/js/api.js'; + +const logoutBtn = document.getElementById('nav-logout'); +if (logoutBtn) { + logoutBtn.addEventListener('click', async () => { + try { + await api('POST', '/api/v1/auth/logout', {}); + } catch (e) { /* session may already be gone */ } + window.location.href = '/login'; + }); +} + +const themeBtn = document.getElementById('nav-theme'); +if (themeBtn) { + themeBtn.addEventListener('click', async () => { + const el = document.documentElement; + const next = el.dataset.theme === 'dark' ? 'light' : 'dark'; + el.dataset.theme = next; + try { localStorage.setItem('theme', next); } catch (e) { /* ignore */ } + if (document.body.dataset.loggedIn === '1') { + try { + await api('PATCH', '/api/v1/profile', { settings: { theme: next } }); + } catch (e) { + toast('Could not save theme preference: ' + e.message, 'err'); + } + } + }); +} diff --git a/web/static/js/profile.js b/web/static/js/profile.js new file mode 100644 index 0000000..142c24f --- /dev/null +++ b/web/static/js/profile.js @@ -0,0 +1,99 @@ +import { api, toast } from '/static/js/api.js'; + +const form = document.getElementById('profile-form'); +const errorBox = document.getElementById('error'); +const okBox = document.getElementById('ok'); +const extraRows = document.getElementById('extra-rows'); + +let version = parseInt(form.dataset.version, 10); + +function addExtraRow(key, value) { + const row = document.createElement('div'); + row.className = 'kv-row'; + const k = document.createElement('input'); + k.type = 'text'; k.placeholder = 'Field name'; k.value = key || ''; + k.setAttribute('aria-label', 'Field name'); + const v = document.createElement('input'); + v.type = 'text'; v.placeholder = 'Value'; v.value = value || ''; + v.setAttribute('aria-label', 'Field value'); + const rm = document.createElement('button'); + rm.type = 'button'; rm.className = 'btn small'; rm.textContent = '×'; + rm.setAttribute('aria-label', 'Remove field'); + rm.addEventListener('click', () => row.remove()); + row.append(k, v, rm); + extraRows.appendChild(row); +} + +async function loadExtra() { + const me = await api('GET', '/api/v1/auth/me'); + version = me.user.version; + const extra = me.user.extra || {}; + Object.keys(extra).forEach((k) => addExtraRow(k, String(extra[k]))); +} +loadExtra().catch((e) => { errorBox.textContent = e.message; }); + +document.getElementById('extra-add').addEventListener('click', () => addExtraRow('', '')); + +form.addEventListener('submit', async (e) => { + e.preventDefault(); + errorBox.textContent = ''; + okBox.textContent = ''; + const extra = {}; + extraRows.querySelectorAll('.kv-row').forEach((row) => { + const [k, v] = row.querySelectorAll('input'); + if (k.value.trim()) extra[k.value.trim()] = v.value; + }); + const links = document.getElementById('p-links').value + .split('\n').map((s) => s.trim()).filter(Boolean); + try { + const res = await api('PATCH', '/api/v1/profile', { + version, + name: document.getElementById('p-name').value.trim(), + bio: document.getElementById('p-bio').value, + contact: { + phone: document.getElementById('p-phone').value.trim(), + location: document.getElementById('p-location').value.trim(), + links, + }, + extra, + settings: { theme: document.getElementById('p-theme').value }, + }); + version = res.user.version; + const theme = res.user.settings.theme; + document.documentElement.dataset.theme = theme; + try { localStorage.setItem('theme', theme); } catch (err) { /* ignore */ } + okBox.textContent = 'Profile saved.'; + } catch (err) { + if (err.status === 409) { + errorBox.textContent = 'This profile changed elsewhere — reload the page and try again.'; + } else { + errorBox.textContent = err.message; + } + } +}); + +document.getElementById('avatar-file').addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + const fd = new FormData(); + fd.append('file', file); + try { + const res = await api('POST', '/api/v1/profile/avatar', fd); + const img = document.createElement('img'); + img.className = 'avatar large'; + img.alt = 'Current avatar'; + img.src = '/files/' + res.fileId + '?v=' + Date.now(); + const preview = document.getElementById('avatar-preview'); + preview.replaceChildren(img); + toast('Avatar updated.', 'ok'); + } catch (err) { + toast('Avatar upload failed: ' + err.message, 'err'); + } +}); + +document.getElementById('logout-all').addEventListener('click', async () => { + try { + await api('POST', '/api/v1/auth/logout-all', {}); + } catch (e) { /* session is gone either way */ } + window.location.href = '/login'; +}); diff --git a/web/static/js/register.js b/web/static/js/register.js new file mode 100644 index 0000000..cac314e --- /dev/null +++ b/web/static/js/register.js @@ -0,0 +1,19 @@ +import { api } from '/static/js/api.js'; + +const form = document.getElementById('register-form'); +const errorBox = document.getElementById('error'); + +form.addEventListener('submit', async (e) => { + e.preventDefault(); + errorBox.textContent = ''; + try { + await api('POST', '/api/v1/auth/register', { + name: document.getElementById('name').value.trim(), + email: document.getElementById('email').value.trim(), + password: document.getElementById('password').value, + }); + window.location.href = '/'; + } catch (err) { + errorBox.textContent = err.message; + } +}); diff --git a/web/static/js/theme.js b/web/static/js/theme.js new file mode 100644 index 0000000..9f8f07b --- /dev/null +++ b/web/static/js/theme.js @@ -0,0 +1,11 @@ +// Theme bootstrap: runs synchronously in to avoid a flash of the +// wrong theme. The server renders data-default-theme from the user profile; +// localStorage wins so the choice applies before login too. +(function () { + var el = document.documentElement; + var saved = null; + try { saved = localStorage.getItem('theme'); } catch (e) { /* private mode */ } + var theme = saved || el.dataset.defaultTheme || 'light'; + if (theme !== 'light' && theme !== 'dark') theme = 'light'; + el.dataset.theme = theme; +})(); diff --git a/web/templates/change-password.html b/web/templates/change-password.html new file mode 100644 index 0000000..4e9fe78 --- /dev/null +++ b/web/templates/change-password.html @@ -0,0 +1,24 @@ +{{define "content"}} +You must set a new password before continuing.
+ {{end}} + + +This area is under construction — it arrives in a later build phase.
+ Back to home +This account signs in via SSO and has no local password.
+ {{end}} + +Revokes every active session for your account, including this one.
+Self-registration creates a developer account. Consultant and admin roles are granted by an administrator.
+ + +