feat: base UI shell with themes, auth pages, profile, and role navigation (phase 4)
- embedded Go templates + vanilla ES-module JS + hand-written CSS, no build step
- exact §10 beige light / dark design tokens, square edges (radius 2px),
system font stack, visible focus rings
- theme toggle persisted to localStorage and the user profile
- login/register/change-password pages wired to the auth API
- profile page: avatar upload (image-sniffed, old file cleanup), bio,
contacts, arbitrary extra key/value fields, optimistic-concurrency 409
- role-based top navigation with placeholders for later-phase areas
- GET /files/{id} with scope-based access (session) or signed token (§5.1)
- security headers incl. CSP without unsafe-inline scripts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 `<head>` 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/* Bounty Board — hand-written CSS, no framework (§2.2).
|
||||
Theming via CSS custom properties on <html data-theme>. */
|
||||
|
||||
: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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
// Theme bootstrap: runs synchronously in <head> 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;
|
||||
})();
|
||||
@@ -0,0 +1,24 @@
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<h1>Change password</h1>
|
||||
{{if .User.MustChangePassword}}
|
||||
<p class="hint">You must set a new password before continuing.</p>
|
||||
{{end}}
|
||||
<div class="error-box" id="error" role="alert"></div>
|
||||
<form id="change-password-form" class="mt">
|
||||
<div class="field">
|
||||
<label for="current">Current password</label>
|
||||
<input type="password" id="current" autocomplete="current-password" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new">New password</label>
|
||||
<input type="password" id="new" autocomplete="new-password" minlength="8" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="confirm">Confirm new password</label>
|
||||
<input type="password" id="confirm" autocomplete="new-password" minlength="8" required>
|
||||
</div>
|
||||
<button class="btn primary" type="submit">Change password</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,41 @@
|
||||
{{define "content"}}
|
||||
<h1>Welcome, {{.User.Name}}</h1>
|
||||
<div class="grid">
|
||||
{{if .User.Roles.Developer}}
|
||||
<div class="card">
|
||||
<h2>Bounty Board</h2>
|
||||
<p class="muted">Browse published tasks and request assignments.</p>
|
||||
<a class="btn primary" href="/board">Open board</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>My Tasks</h2>
|
||||
<p class="muted">Track your assigned and in-progress work.</p>
|
||||
<a class="btn" href="/my-tasks">Open my tasks</a>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .User.Roles.Consultant}}
|
||||
<div class="card">
|
||||
<h2>Atomization Board</h2>
|
||||
<p class="muted">Subdivide imported tickets and publish bounties.</p>
|
||||
<a class="btn primary" href="/consultant/board">Open atomization</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Review Queue</h2>
|
||||
<p class="muted">Approve or request changes on submitted work.</p>
|
||||
<a class="btn" href="/consultant/reviews">Open reviews</a>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .User.Roles.Admin}}
|
||||
<div class="card">
|
||||
<h2>Administration</h2>
|
||||
<p class="muted">Customers, users, settings, audit log, service status.</p>
|
||||
<a class="btn primary" href="/admin">Open admin</a>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="card">
|
||||
<h2>Messages</h2>
|
||||
<p class="muted">Direct, group and project conversations.</p>
|
||||
<a class="btn" href="/messages">Open messages</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,55 @@
|
||||
{{define "layout"}}<!doctype html>
|
||||
<html lang="en" data-theme="light" data-default-theme="{{.DefaultTheme}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} · Bounty Board</title>
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
<script src="/static/js/theme.js"></script>
|
||||
</head>
|
||||
<body {{if .User}}data-logged-in="1"{{end}}>
|
||||
<nav class="topnav">
|
||||
<a class="brand" href="/">Bounty Board</a>
|
||||
<div class="links">
|
||||
{{if .User}}
|
||||
{{if .User.Roles.Developer}}
|
||||
<a href="/board" {{if eq .Active "board"}}aria-current="page"{{end}}>Bounty Board</a>
|
||||
<a href="/my-tasks" {{if eq .Active "my-tasks"}}aria-current="page"{{end}}>My Tasks</a>
|
||||
{{end}}
|
||||
{{if .User.Roles.Consultant}}
|
||||
<a href="/consultant/board" {{if eq .Active "atomization"}}aria-current="page"{{end}}>Atomization</a>
|
||||
<a href="/consultant/reviews" {{if eq .Active "reviews"}}aria-current="page"{{end}}>Reviews</a>
|
||||
<a href="/consultant/pool" {{if eq .Active "pool"}}aria-current="page"{{end}}>Pool</a>
|
||||
{{end}}
|
||||
{{if .User.Roles.Admin}}
|
||||
<a href="/admin" {{if eq .Active "admin"}}aria-current="page"{{end}}>Admin</a>
|
||||
{{end}}
|
||||
<a href="/messages" {{if eq .Active "messages"}}aria-current="page"{{end}}>Messages</a>
|
||||
<a href="/metrics" {{if eq .Active "metrics"}}aria-current="page"{{end}}>Metrics</a>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="right">
|
||||
<button class="btn ghost small" id="nav-theme" type="button" aria-label="Toggle dark / light theme">◐</button>
|
||||
{{if .User}}
|
||||
<a href="/profile" aria-label="Your profile">
|
||||
{{if .User.AvatarFileID}}
|
||||
<img class="avatar" src="/files/{{.User.AvatarFileID}}" alt="">
|
||||
{{else}}
|
||||
<span class="avatar" aria-hidden="true">{{initials .User.Name}}</span>
|
||||
{{end}}
|
||||
</a>
|
||||
<button class="btn small" id="nav-logout" type="button">Log out</button>
|
||||
{{else}}
|
||||
<a class="btn small" href="/login">Log in</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</nav>
|
||||
<main class="container{{if .Narrow}} narrow{{end}}">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
<div id="toasts" aria-live="polite"></div>
|
||||
<script type="module" src="/static/js/nav.js"></script>
|
||||
{{range .Scripts}}<script type="module" src="{{.}}"></script>
|
||||
{{end}}
|
||||
</body>
|
||||
</html>{{end}}
|
||||
@@ -0,0 +1,25 @@
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<h1>Log in</h1>
|
||||
<div class="error-box" id="error" role="alert">{{.Error}}</div>
|
||||
<form id="login-form">
|
||||
<div class="field">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" autocomplete="email" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<div class="spread">
|
||||
<button class="btn primary" type="submit">Log in</button>
|
||||
<a href="/register">Create an account</a>
|
||||
</div>
|
||||
</form>
|
||||
{{if .OIDCEnabled}}
|
||||
<div class="mt">
|
||||
<a class="btn" href="/api/v1/auth/oidc/login">Continue with SSO</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,7 @@
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<h1>{{.Title}}</h1>
|
||||
<p class="muted">This area is under construction — it arrives in a later build phase.</p>
|
||||
<a class="btn" href="/">Back to home</a>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,78 @@
|
||||
{{define "content"}}
|
||||
<h1>Your profile</h1>
|
||||
<div class="error-box" id="error" role="alert"></div>
|
||||
<div class="ok-box" id="ok" role="status"></div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Avatar</h2>
|
||||
<div class="spread">
|
||||
<span id="avatar-preview">
|
||||
{{if .User.AvatarFileID}}
|
||||
<img class="avatar large" src="/files/{{.User.AvatarFileID}}" alt="Current avatar">
|
||||
{{else}}
|
||||
<span class="avatar large" aria-hidden="true">{{initials .User.Name}}</span>
|
||||
{{end}}
|
||||
</span>
|
||||
<div>
|
||||
<label for="avatar-file">Upload new avatar (image)</label>
|
||||
<input type="file" id="avatar-file" accept="image/*">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="profile-form" class="card" data-version="{{.User.Version}}">
|
||||
<h2>Basics</h2>
|
||||
<div class="field">
|
||||
<label for="p-name">Name</label>
|
||||
<input type="text" id="p-name" value="{{.User.Name}}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="p-bio">Bio</label>
|
||||
<textarea id="p-bio">{{.User.Bio}}</textarea>
|
||||
</div>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="p-phone">Phone</label>
|
||||
<input type="text" id="p-phone" value="{{.User.Contact.Phone}}" autocomplete="tel">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="p-location">Location</label>
|
||||
<input type="text" id="p-location" value="{{.User.Contact.Location}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="p-links">Links (one per line)</label>
|
||||
<textarea id="p-links">{{range .User.Contact.Links}}{{.}}
|
||||
{{end}}</textarea>
|
||||
</div>
|
||||
|
||||
<h2>Additional fields</h2>
|
||||
<p class="hint">Arbitrary key/value details shown on your profile card.</p>
|
||||
<div id="extra-rows"></div>
|
||||
<button class="btn small" type="button" id="extra-add">+ Add field</button>
|
||||
|
||||
<h2 class="mt">Appearance</h2>
|
||||
<div class="field">
|
||||
<label for="p-theme">Theme</label>
|
||||
<select id="p-theme">
|
||||
<option value="light" {{if eq .User.Settings.Theme "light"}}selected{{end}}>Light (beige)</option>
|
||||
<option value="dark" {{if eq .User.Settings.Theme "dark"}}selected{{end}}>Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button class="btn primary" type="submit">Save profile</button>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
<h2>Security</h2>
|
||||
{{if .HasLocalAuth}}
|
||||
<p><a class="btn" href="/change-password">Change password</a></p>
|
||||
{{else}}
|
||||
<p class="muted">This account signs in via SSO and has no local password.</p>
|
||||
{{end}}
|
||||
<p><button class="btn danger" type="button" id="logout-all">Log out everywhere</button></p>
|
||||
<p class="hint">Revokes every active session for your account, including this one.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,26 @@
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<h1>Create account</h1>
|
||||
<p class="hint">Self-registration creates a developer account. Consultant and admin roles are granted by an administrator.</p>
|
||||
<div class="error-box" id="error" role="alert"></div>
|
||||
<form id="register-form" class="mt">
|
||||
<div class="field">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" autocomplete="name" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" autocomplete="email" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" autocomplete="new-password" minlength="8" required>
|
||||
<p class="hint">At least 8 characters.</p>
|
||||
</div>
|
||||
<div class="spread">
|
||||
<button class="btn primary" type="submit">Create account</button>
|
||||
<a href="/login">I already have an account</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Package web embeds all frontend assets: Go templates and static files.
|
||||
// No build step — the binary serves everything (§2.2).
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed templates static
|
||||
var FS embed.FS
|
||||
Reference in New Issue
Block a user