diff --git a/Makefile b/Makefile index 4daf58d..793f92e 100644 --- a/Makefile +++ b/Makefile @@ -29,14 +29,20 @@ lint: echo "gofmt needed on:"; echo "$$unformatted"; exit 1; fi $(GO) vet ./... +# Requires Mongo published on loopback (test overlay) or MONGO_SEED_URI. seed: - @echo "seed script lands in phase 12 (scripts/seed.go)"; exit 1 + $(GO) run ./scripts smoke: ./scripts/smoke.sh +# mongodump/mongorestore through the mongo container into ./backups (§11.23) backup: - @echo "backup target lands in phase 12"; exit 1 + @mkdir -p backups + docker compose exec -T mongo mongodump --archive --db=bountyboard > backups/bountyboard-$$(date +%Y%m%d-%H%M%S).archive + @ls -lh backups/ | tail -1 +# usage: make restore FILE=backups/bountyboard-20260612-120000.archive restore: - @echo "restore target lands in phase 12"; exit 1 + @test -n "$(FILE)" || (echo "usage: make restore FILE=backups/"; exit 1) + docker compose exec -T mongo mongorestore --archive --drop < $(FILE) diff --git a/PROGRESS.md b/PROGRESS.md index 71ef961..80b0834 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -12,3 +12,4 @@ - Phase 9 (messaging): conversations (dm dedupe/group/project), sanitized rich-text messages, file/image attachments (generic POST /api/v1/files w/ rate limit), per-conversation unread counts + mark-read, chat-file access limited to participants, mentions→notifications, typing indicator over WS inbound relay, two-pane messages UI (contenteditable composer, formatting toolbar, drag&drop upload, inline previews + lightbox, live delivery, polling fallback), user directory search — integration tests green. - Phase 10 (metrics): aggregation pipelines over bountyAwards + timelines (totals, weekly $dateTrunc buckets, per-developer/per-customer, approval rate, time logged, assign→approve lead time, atomization lead time, open board depth), CSV export, leaderboard w/ opt-out (profile setting), dependency-free SVG line+bar charts, developer/consultant/admin dashboards — integration tests green. - Phase 11 (mock services): services/atomizer (own Go module, ports 8090, bearer auth, OpenAI-compatible chat completions + native /v1/messages via LLM_API_STYLE, strict-JSON prompt, fence-stripping defensive parse, one retry, deterministic equal-split fallback, exact sum normalization); services/work-performer (Node 20, zero deps, single-concurrency queue, TASK.md + attachment download + optional git clone, claude -p --output-format json --dangerously-skip-permissions, simulated result when CLI unavailable, artifact serving, HMAC-signed callbacks with retry); compose profile mocks w/ healthchecks + ${HOME}/.claude mounts; contract tests pass live (WP ran real Claude Code via mounted host creds). UFW rule added for container→host callbacks. +- Phase 12 (polish): seed script (make seed: demo customer + 1 admin + 2 consultants + 6 devs + pools + conversations, idempotent), forgot/reset password (SMTP-gated, one-shot TTL tokens, anti-enumeration), profile hover cards, keyboard shortcuts (g b / g m / g t / g h / focus search), bulk archive endpoint, hand-written api/openapi.yaml served at /api/docs, make backup/restore via mongodump, README w/ runbook + Caddy & nginx samples (WS + client_max_body_size), extended register→login→board smoke script — all tests green. diff --git a/README.md b/README.md index e69de29..1933c5c 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,188 @@ +# Bounty Board + +A consulting work-management platform: imports tickets from Jira / Azure +DevOps / YouTrack (or an offline `demo` source), lets **consultants** atomize +them into small developer tasks with AI assistance, publishes them on a +**bounty board** where **developers** claim work (or an **AI work performer** +does it), and tracks review, approval, and bounty-based metrics. + +Built per [specification.md](specification.md): Go ≥ 1.22 + stdlib `net/http`, +MongoDB, server-rendered templates + vanilla ES modules (no build step), +Docker Compose deployment. Architectural decisions are logged in +[DECISIONS.md](DECISIONS.md); build history in [PROGRESS.md](PROGRESS.md). + +## Quick start + +```bash +cp .env.example .env +# then edit .env: +# SESSION_SECRET=$(openssl rand -base64 32) +# CREDENTIALS_ENC_KEY=$(openssl rand -base64 32) +# ADMIN_INITIAL_PASSWORD= +# ATOMIZER_TOKEN / WORK_PERFORMER_TOKEN = random strings +# ANTHROPIC_API_KEY= # optional; offline fallback without it + +docker compose --profile mocks up -d --build +open http://localhost:8787 +``` + +First login: `ADMIN_EMAIL` / `ADMIN_INITIAL_PASSWORD` from `.env` — a +password change is forced immediately. + +Without `--profile mocks` only `app` + `mongo` start and the external +service URLs in `.env` must point at real implementations of the §5 +contracts. + +### Demo data + +```bash +docker compose -f docker-compose.yml -f docker-compose.test.yml up -d # publishes mongo on loopback +make seed +``` + +Seeds a demo customer (offline `demo` ticketing — tickets appear within one +poll), consultants `clara@example.com` / `carlos@example.com`, developers +`dev1@example.com` … `dev6@example.com` (password `demo-pass-123` for all), +pools, and sample conversations. + +## Services + +| Service | Port (loopback) | What | +|---|---|---| +| `app` | 8787 | Bounty Board (UI + API + WS) | +| `mongo` | — (never published; test overlay adds 27017) | database | +| `atomizer-mock` | 8090 (profile `mocks`) | §5.1 Atomization Service — Anthropic chat completions (or native `/v1/messages` via `LLM_API_STYLE=anthropic`), deterministic fallback without an API key | +| `work-performer` | 8091 (profile `mocks`) | §5.2 Work Performer — runs Claude Code with the host's `~/.claude` mounted read-only; simulated result when the CLI is unavailable | + +The two external services are intentionally independent: separate codebases +(`services/atomizer`, `services/work-performer`), containers, ports, and +bearer tokens. Swap either by changing its base URL + token in `.env`. + +## Development + +```bash +make build # app + atomizer mock +make test # unit tests (no Mongo needed) +make test-integration # needs mongo on loopback (test overlay) +make test-contract # needs the mocks profile running +make lint # gofmt + go vet +make run # run the app locally (expects Mongo + .env) +make smoke # curl smoke test against a running stack +``` + +API reference: `GET /api/docs` (rendered) or [api/openapi.yaml](api/openapi.yaml). + +## Operations runbook + +**Logs** — JSON to stdout: `docker compose logs -f app` (request ids, sync +runs, job retries). `docker compose logs -f atomizer-mock work-performer` +for the mocks. + +**Health** — `GET /healthz` (liveness), `GET /readyz` (Mongo required; +atomizer/work-performer reported non-fatally), `GET /metricsz` (JSON +counters: requests, sync runs, job queue depths). The admin UI → +*Service status* shows live health, latency, circuit-breaker state, sync +workers, and queue depths. + +**Backups** (§11.23): + +```bash +make backup # mongodump → ./backups/bountyboard-.archive +make restore FILE=backups/ # mongorestore --drop +``` + +Cron example: `0 3 * * * cd /opt/bountyboard && make backup >/dev/null`. + +**Circuit breakers** — after 3 consecutive failures calls to an external +service stop for 60 s (one half-open probe per minute afterwards). The +consultant board disables Subdivide/Extend while the atomizer is down; AI +assignment fails fast while the work performer is down. Each service has an +independent breaker. + +**Stuck jobs** — background jobs (`jobs` collection) retry 3× with backoff; +jobs stuck `running` after a crash are re-queued automatically within +5 minutes. + +## Reverse proxy + +The app expects a TLS-terminating proxy (§12). Set in `.env`: + +```dotenv +APP_BASE_URL=https://bounty.example.com +TRUSTED_PROXY_CIDRS=172.16.0.0/12 # the proxy's source range as seen by the app +COOKIE_SECURE=auto +``` + +`X-Forwarded-*` headers are honored only from `TRUSTED_PROXY_CIDRS`; the +resolved client IP feeds rate limiting and the audit log. WebSocket +heartbeats (30 s) keep idle proxy timeouts from killing `/ws`. + +### Caddy + +```caddyfile +bounty.example.com { + encode gzip + reverse_proxy 127.0.0.1:8787 + # WebSockets are proxied automatically; raise the body limit to match MAX_UPLOAD_MB + request_body { + max_size 25MB + } +} +``` + +### nginx + +```nginx +server { + listen 443 ssl http2; + server_name bounty.example.com; + ssl_certificate /etc/letsencrypt/live/bounty.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/bounty.example.com/privkey.pem; + + client_max_body_size 25m; # match MAX_UPLOAD_MB + + location /ws { + proxy_pass http://127.0.0.1:8787; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; # > the 30s WS heartbeat + } + + location / { + proxy_pass http://127.0.0.1:8787; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + } +} +``` + +## Configuration + +All configuration comes from `.env` (see `.env.example` for every knob with +comments): Mongo, session/credential secrets, bootstrap admin, the two +external service URLs + tokens + timeouts, SMTP (enables forgot-password +emails when set), OIDC SSO (buttons appear when set), upload limit, rate +limits, and the mock services' Anthropic settings. + +Consultants map their ticketing identities in their profile via +`users.extra.ticketingIdentities` (e.g. set by an admin): +`{"jira": "consultant@corp.com", "azure_devops": "consultant@corp.com", "youtrack": "consultant.login"}`. +The `demo` ticketing type needs no identity. + +## Deliberately out of scope (documented stubs, §11.24) + +- **Webhook ingestion** instead of polling — the sync layer is keyed on an + idempotent `(system, key, customerId)` upsert, so a webhook receiver can + reuse `UpsertImportedTask` unchanged. +- **Status write-back** to customer systems — v1 sync is read-only. +- **Email digests** — notifications are in-app + WS; the mailer exists and + is used for password resets. +- **Production AI work performer** — the §5.2 HTTP contract is final; the + shipped container is a placeholder. +- **Localization** — UI strings are English-only. diff --git a/api/api.go b/api/api.go new file mode 100644 index 0000000..339d1f3 --- /dev/null +++ b/api/api.go @@ -0,0 +1,7 @@ +// Package api embeds the hand-written OpenAPI document (§6). +package api + +import _ "embed" + +//go:embed openapi.yaml +var OpenAPI []byte diff --git a/api/openapi.yaml b/api/openapi.yaml new file mode 100644 index 0000000..8b6955d --- /dev/null +++ b/api/openapi.yaml @@ -0,0 +1,289 @@ +openapi: "3.1.0" +info: + title: Bounty Board API + version: "1.0" + description: | + Consulting work-management platform API (spec §6). All endpoints are + session-authenticated (httpOnly cookie `bb_session`); mutations also + require the CSRF double-submit header `X-CSRF-Token` mirroring the + `bb_csrf` cookie. Errors use the envelope + `{"error":{"code":"…","message":"…"}}`. Pagination: `?limit=&cursor=`. +servers: + - url: /api/v1 +tags: + - {name: auth} + - {name: admin} + - {name: consultant} + - {name: developer} + - {name: tasks} + - {name: messaging} + - {name: shared} + - {name: internal} + +paths: + /auth/register: + post: + tags: [auth] + summary: Self-register a developer account + requestBody: {$ref: "#/components/requestBodies/Register"} + responses: + "201": {description: Account created and session started} + "409": {description: Email already registered} + /auth/login: + post: + tags: [auth] + summary: Local login + requestBody: {$ref: "#/components/requestBodies/Login"} + responses: + "200": {description: "Session cookies set; body: user + mustChangePassword"} + "401": {description: Invalid credentials} + "429": {description: Rate limited (10 attempts / 15 min / IP+email)} + /auth/logout: + post: {tags: [auth], summary: Revoke the current session, responses: {"204": {description: Logged out}}} + /auth/logout-all: + post: {tags: [auth], summary: Revoke every session of the current user, responses: {"200": {description: Count of revoked sessions}}} + /auth/change-password: + post: + tags: [auth] + summary: Change the local password (revokes other sessions) + responses: {"204": {description: Changed}, "401": {description: Wrong current password}} + /auth/forgot: + post: + tags: [auth] + summary: Request a password-reset email (SMTP required) + responses: {"200": {description: Uniform response}, "503": {description: SMTP not configured}} + /auth/reset: + post: + tags: [auth] + summary: Set a new password with a one-shot token + responses: {"204": {description: Password set}, "400": {description: Invalid or expired token}} + /auth/me: + get: {tags: [auth], summary: Current user, responses: {"200": {description: User profile + flags}}} + /auth/oidc/login: + get: {tags: [auth], summary: Start the OIDC code flow with PKCE (302 to the IdP), responses: {"302": {description: Redirect}}} + /auth/oidc/callback: + get: {tags: [auth], summary: OIDC redirect URI (links by verified email or creates a developer), responses: {"302": {description: Redirect home or to /login?error=…}}} + + /admin/users: + get: + tags: [admin] + summary: List/search users + parameters: + - {name: q, in: query, schema: {type: string}} + - {name: role, in: query, schema: {type: string, enum: [admin, consultant, developer]}} + responses: {"200": {description: Users + nextCursor}} + /admin/users/{id}: + patch: + tags: [admin] + summary: "Update roles / disabled / name / force password reset" + responses: {"200": {description: Updated user}, "400": {description: Self-lockout guard}, "409": {description: Version conflict}} + delete: {tags: [admin], summary: Delete a user and their sessions, responses: {"204": {description: Deleted}}} + /admin/customers: + get: {tags: [admin], summary: List customers (includeArchived=true optional), responses: {"200": {description: Customers (credentials never returned)}}} + post: + tags: [admin] + summary: Create a customer with per-system credentials (encrypted at rest) + requestBody: {$ref: "#/components/requestBodies/Customer"} + responses: {"201": {description: Created}, "409": {description: Name taken}} + /admin/customers/{id}: + get: {tags: [admin], summary: Get one customer, responses: {"200": {description: Customer}}} + patch: {tags: [admin], summary: Partial update (credentials rotate when supplied), responses: {"200": {description: Updated}, "409": {description: Version conflict}}} + delete: {tags: [admin], summary: Delete (blocked while tasks exist), responses: {"204": {description: Deleted}, "409": {description: Has tasks — archive instead}}} + /admin/customers/test-connection: + post: {tags: [admin], summary: Test unsaved credentials (wizard), responses: {"200": {description: "{ok, latencyMs, error?}"}}} + /admin/customers/{id}/test-connection: + post: {tags: [admin], summary: Test the stored connection, responses: {"200": {description: "{ok, latencyMs, error?}"}}} + /admin/customers/{id}/sync-now: + post: {tags: [admin], summary: Trigger an immediate sync poll, responses: {"202": {description: Queued}}} + /admin/settings: + get: {tags: [admin], summary: Runtime settings, responses: {"200": {description: Settings}}} + patch: {tags: [admin], summary: Update runtime settings (atomizer URL override, branding, …), responses: {"200": {description: Updated settings}}} + /admin/audit-log: + get: + tags: [admin] + summary: Audit log of privileged mutations + parameters: + - {name: actorId, in: query, schema: {type: string}} + - {name: entityId, in: query, schema: {type: string}} + - {name: cursor, in: query, schema: {type: string}} + responses: {"200": {description: Entries + nextCursor}} + /admin/service-status: + get: {tags: [admin], summary: "Health/latency/breaker for atomizer + work performer, sync workers, job queue (§11.17)", responses: {"200": {description: Status payload}}} + + /consultant/board: + get: + tags: [consultant] + summary: Atomization board (imported…claim_requested tasks of own customers) + parameters: + - {name: customerId, in: query, schema: {type: string}} + - {name: status, in: query, schema: {type: string}} + responses: {"200": {description: Customers + tasks}} + /consultant/reviews: + get: {tags: [consultant], summary: Tasks in review for own customers, responses: {"200": {description: Tasks}}} + /consultant/pool: + get: {tags: [consultant], summary: Global developer pool with membership flags, responses: {"200": {description: Developers}}} + post: {tags: [consultant], summary: Add a developer to my pool, responses: {"201": {description: Added}, "409": {description: Already in pool}}} + /consultant/pool/{developerId}: + delete: {tags: [consultant], summary: Remove a developer from my pool, responses: {"204": {description: Removed}}} + /consultant/metrics: + get: + tags: [consultant] + summary: "Aggregations per developer/customer + lead time + board depth (§6.2); format=csv exports the ledger" + parameters: + - {name: customerId, in: query, schema: {type: string}} + - {name: developerId, in: query, schema: {type: string}} + - {name: from, in: query, schema: {type: string, format: date}} + - {name: to, in: query, schema: {type: string, format: date}} + - {name: format, in: query, schema: {type: string, enum: [csv]}} + responses: {"200": {description: Metrics JSON or CSV}} + + /board: + get: + tags: [developer] + summary: Bounty board (published tasks of consultants who pooled me) + parameters: + - {name: customerId, in: query, schema: {type: string}} + - {name: q, in: query, schema: {type: string}, description: Mongo text search} + - {name: minBounty, in: query, schema: {type: number}} + - {name: sort, in: query, schema: {type: string, enum: ["", bounty]}} + responses: {"200": {description: Tasks + customers}} + /my-tasks: + get: {tags: [developer], summary: My kanban (assigned…approved), responses: {"200": {description: Tasks}}} + /developer/metrics: + get: + tags: [developer] + summary: "Own §6.2 metrics; format=csv exports the ledger" + parameters: + - {name: from, in: query, schema: {type: string, format: date}} + - {name: to, in: query, schema: {type: string, format: date}} + - {name: format, in: query, schema: {type: string, enum: [csv]}} + responses: {"200": {description: Metrics JSON or CSV}} + /leaderboard: + get: {tags: [shared], summary: Top developers by bounty (opt-out honored), responses: {"200": {description: Leaderboard}}} + + /tasks/{id}: + get: {tags: [tasks], summary: Task detail incl. timeline (role-scoped), responses: {"200": {description: Task}, "403": {description: No access}}} + patch: {tags: [consultant], summary: "Edit title/description/AC/coefficient/budget (version-checked, bounty recomputed; cascadeBudget optional)", responses: {"200": {description: Updated task}, "409": {description: Conflict or immutable}}} + /tasks/{id}/subdivide: + post: {tags: [consultant], summary: "Queue AI subdivision (§5.1); re-run needs confirmReplace", responses: {"202": {description: "{jobId}"}, "409": {description: Bad status / confirm required}}} + /tasks/{id}/extend: + post: {tags: [consultant], summary: Queue AI extension (note required), responses: {"202": {description: "{jobId}"}}} + /tasks/{id}/publish: + post: {tags: [consultant], summary: Publish an atomized task to the board, responses: {"200": {description: Task}, "409": {description: Illegal transition}}} + /tasks/publish: + post: {tags: [consultant], summary: "Bulk publish {ids:[…]}", responses: {"200": {description: "{published, failed}"}}} + /tasks/archive: + post: {tags: [consultant], summary: "Bulk archive {ids:[…]}", responses: {"200": {description: "{archived, failed}"}}} + /tasks/{id}/archive: + post: {tags: [tasks], summary: Archive (consultant/admin), responses: {"204": {description: Archived}}} + /tasks/{id}/claim: + post: {tags: [developer], summary: Request assignment (optional pitch note), responses: {"204": {description: Requested}, "409": {description: Already requested / not open}}} + /tasks/{id}/claim/withdraw: + post: {tags: [developer], summary: Withdraw my claim (back to published when none remain), responses: {"204": {description: Withdrawn}}} + /tasks/{id}/approve-claim: + post: {tags: [consultant], summary: "Approve one developer {developerId}; others are declined + notified", responses: {"204": {description: Assigned}}} + /tasks/{id}/decline-claim: + post: {tags: [consultant], summary: Decline one claim, responses: {"204": {description: Declined}}} + /tasks/{id}/assign-ai: + post: {tags: [consultant], summary: "Create a Work Performer job (§5.2) {context:{repositoryUrl,branch,instructions}}", responses: {"202": {description: "{jobId}"}, "502": {description: Performer rejected the job}}} + /tasks/{id}/unassign: + post: {tags: [consultant], summary: Return an assigned/in-progress task to the board, responses: {"204": {description: Unassigned}}} + /tasks/{id}/start: + post: {tags: [developer], summary: Start work (assigned or changes_requested), responses: {"204": {description: In progress}}} + /tasks/{id}/submit-review: + post: {tags: [developer], summary: Submit for review, responses: {"204": {description: In review}}} + /tasks/{id}/abandon: + post: {tags: [developer], summary: Abandon back to the board, responses: {"204": {description: Published}}} + /tasks/{id}/comments: + post: {tags: [tasks], summary: Add a sanitized comment (@mentions notify), responses: {"201": {description: Comment}}} + /tasks/{id}/time: + post: {tags: [developer], summary: "Log time {minutes, note}", responses: {"201": {description: Entry}}} + /tasks/{id}/review: + post: {tags: [consultant], summary: "Review {decision: approve|request_changes, note, checklist[]}; approve freezes the bounty into bountyAwards", responses: {"204": {description: Reviewed}}} + + /conversations: + get: {tags: [messaging], summary: My conversations with unread counts, responses: {"200": {description: Conversations}}} + post: {tags: [messaging], summary: "Create dm (deduplicated) / group / project conversation", responses: {"200": {description: Existing dm}, "201": {description: Created}}} + /conversations/{id}/messages: + get: + tags: [messaging] + summary: Message history (newest page; cursor pages backwards) + parameters: [{name: cursor, in: query, schema: {type: string}}] + responses: {"200": {description: Messages chronological}} + post: {tags: [messaging], summary: "Send {body (sanitized rich text), attachments:[fileIds]}", responses: {"201": {description: Message}}} + /conversations/{id}/read: + post: {tags: [messaging], summary: Mark all messages read, responses: {"200": {description: "{marked}"}}} + + /files: + post: {tags: [shared], summary: "Upload (multipart field `file`; scope=chat default, task for consultants)", responses: {"201": {description: "{fileId, name, mimeType, size, isImage}"}, "413": {description: Exceeds MAX_UPLOAD_MB}}} + /profile: + get: {tags: [shared], summary: Own profile, responses: {"200": {description: User}}} + patch: {tags: [shared], summary: "Partial profile update (name, bio, contact, extra, settings.theme/leaderboardOptOut); optional version → 409 on conflict", responses: {"200": {description: Updated user}}} + /profile/avatar: + post: {tags: [shared], summary: Upload avatar (must sniff as an image), responses: {"200": {description: "{fileId}"}}} + /users: + get: {tags: [shared], summary: User directory search (name/email) for messaging, responses: {"200": {description: Users}}} + /users/{id}/card: + get: {tags: [shared], summary: Public profile card (hover cards), responses: {"200": {description: Card}}} + /notifications: + get: {tags: [shared], summary: My notifications + unread count, responses: {"200": {description: Notifications}}} + /notifications/read: + post: {tags: [shared], summary: "Mark read {ids:[]} (empty = all)", responses: {"200": {description: "{marked}"}}} + /service-health: + get: {tags: [shared], summary: Atomizer + work-performer health/breaker for UI gating, responses: {"200": {description: Health}}} + + /internal/work-results: + post: + tags: [internal] + summary: "Work Performer callback (§5.2): HMAC `X-Signature` over the raw body with WORK_PERFORMER_TOKEN; idempotent by jobId; artifacts ingested into GridFS" + responses: + "200": {description: Accepted or duplicate_ignored} + "401": {description: Bad signature} + +components: + requestBodies: + Register: + content: + application/json: + schema: + type: object + required: [email, name, password] + properties: + email: {type: string, format: email} + name: {type: string} + password: {type: string, minLength: 8} + Login: + content: + application/json: + schema: + type: object + required: [email, password] + properties: + email: {type: string} + password: {type: string} + Customer: + content: + application/json: + schema: + type: object + required: [name, type] + properties: + name: {type: string} + type: {type: string, enum: [jira, azure_devops, youtrack, demo]} + baseUrl: {type: string} + projectKey: {type: string} + pollIntervalSec: {type: integer, minimum: 10} + defaultBudget: {type: number} + consultantIds: {type: array, items: {type: string}} + credentials: + type: object + description: "jira: {email, apiToken} · azure_devops: {organization, project, pat} · youtrack: {permanentToken} · demo: {}" + schemas: + Error: + type: object + properties: + error: + type: object + properties: + code: {type: string} + message: {type: string} diff --git a/internal/auth/mail.go b/internal/auth/mail.go new file mode 100644 index 0000000..b21da96 --- /dev/null +++ b/internal/auth/mail.go @@ -0,0 +1,43 @@ +package auth + +import ( + "fmt" + "net/smtp" + "strings" + + "bountyboard/internal/config" +) + +// Mailer sends plain-text email via SMTP (net/smtp, STARTTLS when offered). +// All features depending on it stay disabled until SMTP_HOST is set (§7). +type Mailer struct { + cfg config.SMTP +} + +func NewMailer(cfg config.SMTP) *Mailer { return &Mailer{cfg: cfg} } + +func (m *Mailer) Enabled() bool { return m.cfg.Enabled() } + +func (m *Mailer) Send(to, subject, body string) error { + if !m.Enabled() { + return fmt.Errorf("smtp is not configured") + } + addr := fmt.Sprintf("%s:%d", m.cfg.Host, m.cfg.Port) + msg := strings.Join([]string{ + "From: " + m.cfg.From, + "To: " + to, + "Subject: " + subject, + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=utf-8", + "", + body, + }, "\r\n") + var a smtp.Auth + if m.cfg.User != "" { + a = smtp.PlainAuth("", m.cfg.User, m.cfg.Password, m.cfg.Host) + } + if err := smtp.SendMail(addr, a, m.cfg.From, []string{to}, []byte(msg)); err != nil { + return fmt.Errorf("smtp send: %w", err) + } + return nil +} diff --git a/internal/http/auth.go b/internal/http/auth.go index 14eb1c0..6c42b18 100644 --- a/internal/http/auth.go +++ b/internal/http/auth.go @@ -24,6 +24,79 @@ func (s *Server) routesAuth(mux *http.ServeMux) { mux.Handle("GET /api/v1/auth/me", s.requireAuth(http.HandlerFunc(s.handleMe))) mux.HandleFunc("GET /api/v1/auth/oidc/login", s.handleOIDCLogin) mux.HandleFunc("GET /api/v1/auth/oidc/callback", s.handleOIDCCallback) + mux.HandleFunc("POST /api/v1/auth/forgot", s.handleForgotPassword) + mux.HandleFunc("POST /api/v1/auth/reset", s.handleResetPassword) +} + +// handleForgotPassword issues a one-shot reset token by email (§11.22). +// Active only when SMTP is configured; never reveals account existence. +func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) { + var req struct { + Email string `json:"email"` + } + if !decodeJSON(w, r, &req) { + return + } + if s.mailer == nil || !s.mailer.Enabled() { + writeError(w, http.StatusServiceUnavailable, "smtp_disabled", + "password reset by email is not configured on this server") + return + } + if !s.loginLimiter.Allow("forgot|" + ClientIP(r.Context()).String()) { + writeError(w, http.StatusTooManyRequests, "rate_limited", "too many attempts") + return + } + u, err := s.store.UserByEmail(r.Context(), req.Email) + if err == nil && u.Auth.Local != nil && !u.Disabled { + token := auth.NewToken() + if err := s.store.CreatePasswordReset(r.Context(), token, u.ID, time.Hour); err != nil { + s.internalError(w, r, "create reset", err) + return + } + link := s.cfg.AppBaseURL + "/reset-password?token=" + token + go func() { + if err := s.mailer.Send(u.Email, "Reset your Bounty Board password", + "Use this link within one hour to set a new password:\n\n"+link+ + "\n\nIf you did not request this, ignore this email."); err != nil { + s.log.Error("send reset mail", "err", err) + } + }() + } + // uniform response regardless of account existence + writeJSON(w, http.StatusOK, map[string]string{"status": "sent_if_account_exists"}) +} + +func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) { + var req struct { + Token string `json:"token"` + NewPassword string `json:"newPassword"` + } + if !decodeJSON(w, r, &req) { + return + } + if len(req.NewPassword) < auth.MinPasswordLen { + writeError(w, http.StatusBadRequest, "weak_password", + fmt.Sprintf("password must be at least %d characters", auth.MinPasswordLen)) + return + } + userID, err := s.store.ConsumePasswordReset(r.Context(), req.Token) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_token", "this reset link is invalid or expired") + return + } + hash, err := auth.HashPassword(req.NewPassword) + if err != nil { + s.internalError(w, r, "hash password", err) + return + } + if err := s.store.SetPassword(r.Context(), userID, hash, false); err != nil { + s.internalError(w, r, "set password", err) + return + } + if _, err := s.store.DeleteUserSessions(r.Context(), userID); err != nil { + s.log.Warn("revoke sessions after reset", "err", err) + } + w.WriteHeader(http.StatusNoContent) } // decodeJSON reads a bounded JSON body into dst, rejecting unknown fields. diff --git a/internal/http/server.go b/internal/http/server.go index c881745..715aae4 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -28,6 +28,7 @@ type Server struct { files *files.Store templates map[string]*template.Template oidc *auth.OIDCClient + mailer *auth.Mailer loginLimiter *auth.RateLimiter checks []ReadinessCheck startedAt time.Time @@ -72,6 +73,7 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store. files: fs, templates: templates, oidc: auth.NewOIDCClient(cfg, log), + mailer: auth.NewMailer(cfg.SMTP), loginLimiter: auth.NewRateLimiter(10, 15*time.Minute), startedAt: time.Now(), } @@ -89,6 +91,7 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store. s.routesWorkResults(mux) s.routesMessages(mux) s.routesMetrics(mux) + s.routesDocs(mux) mux.HandleFunc("GET /ws", s.handleWS) s.routesWeb(mux) diff --git a/internal/http/tasks.go b/internal/http/tasks.go index 11cd37a..ec3f79d 100644 --- a/internal/http/tasks.go +++ b/internal/http/tasks.go @@ -22,6 +22,7 @@ func (s *Server) routesTasks(mux *http.ServeMux) { mux.Handle("POST /api/v1/tasks/{id}/publish", s.authedRole("consultant", s.handlePublishOne)) mux.Handle("POST /api/v1/tasks/publish", s.authedRole("consultant", s.handlePublishBulk)) mux.Handle("POST /api/v1/tasks/{id}/archive", s.authed(s.handleArchiveTask)) + mux.Handle("POST /api/v1/tasks/archive", s.authedRole("consultant", s.handleArchiveBulk)) mux.Handle("GET /api/v1/tasks/{id}", s.requireAuth(http.HandlerFunc(s.handleTaskDetail))) mux.Handle("GET /api/v1/service-health", s.requireAuth(http.HandlerFunc(s.handleServiceHealth))) } @@ -405,6 +406,44 @@ func (s *Server) handleArchiveTask(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// handleArchiveBulk implements §11.9 bulk archive. +func (s *Server) handleArchiveBulk(w http.ResponseWriter, r *http.Request) { + var req struct { + IDs []string `json:"ids"` + } + if !decodeJSON(w, r, &req) { + return + } + if len(req.IDs) == 0 || len(req.IDs) > 100 { + writeError(w, http.StatusBadRequest, "bad_request", "ids must contain 1..100 entries") + return + } + u := CurrentUser(r.Context()) + archived := []string{} + failed := map[string]string{} + for _, id := range req.IDs { + t, err := s.store.TaskByID(r.Context(), id) + if err != nil { + failed[id] = "not found" + continue + } + c, err := s.store.CustomerByID(r.Context(), t.CustomerID) + if err != nil || (!u.Roles.Admin && !c.HasConsultant(u.ID)) { + failed[id] = "not your customer" + continue + } + if err := s.store.TransitionTask(r.Context(), t, domain.StatusArchived, u.ID, "archived", nil, nil); err != nil { + failed[id] = err.Error() + continue + } + archived = append(archived, id) + } + if len(archived) > 0 { + s.Publish("board", "task.archived_bulk", map[string]any{"taskIds": archived}) + } + writeJSON(w, http.StatusOK, map[string]any{"archived": archived, "failed": failed}) +} + // handleTaskDetail is role-scoped: admins and customer consultants always; // developers when they are the assignee, have a claim, or the task is on the // public board (published/claim_requested). diff --git a/internal/http/web.go b/internal/http/web.go index 5ccd908..5cb68d5 100644 --- a/internal/http/web.go +++ b/internal/http/web.go @@ -2,12 +2,14 @@ package httpx import ( "fmt" + "html" "html/template" "io/fs" "net/http" "net/url" "strings" + "bountyboard/api" "bountyboard/internal/domain" "bountyboard/web" ) @@ -155,6 +157,17 @@ func (s *Server) routesWeb(mux *http.ServeMux) { }) }) + for _, p := range []struct{ path, tmpl, title, script string }{ + {"/forgot-password", "forgot-password.html", "Forgot password", "/static/js/forgot-password.js"}, + {"/reset-password", "reset-password.html", "Reset password", "/static/js/reset-password.js"}, + } { + mux.HandleFunc("GET "+p.path, func(w http.ResponseWriter, r *http.Request) { + s.render(w, r, p.tmpl, &pageData{ + Title: p.title, Narrow: true, Scripts: []string{p.script}, + }) + }) + } + 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, @@ -223,6 +236,24 @@ func (s *Server) routesWeb(mux *http.ServeMux) { rolePage("/metrics", "metrics.html", "Metrics", "metrics", "/static/js/metrics.js", nil) } +// routesDocs serves the OpenAPI document and a minimal viewer (§6). +func (s *Server) routesDocs(mux *http.ServeMux) { + mux.HandleFunc("GET /api/openapi.yaml", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/yaml") + w.Write(api.OpenAPI) + }) + mux.HandleFunc("GET /api/docs", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(` +Bounty Board API + +

Bounty Board API

+

Download openapi.yaml

+
` +
+			html.EscapeString(string(api.OpenAPI)) + `
`)) + }) +} + func loginErrorMessage(code string) string { switch code { case "": diff --git a/internal/store/indexes.go b/internal/store/indexes.go index 0eb0470..bd3b44c 100644 --- a/internal/store/indexes.go +++ b/internal/store/indexes.go @@ -51,10 +51,6 @@ func EnsureIndexes(ctx context.Context, db *mongo.Database) error { {Keys: bson.D{{Key: "participantIds", Value: 1}, {Key: "lastMessageAt", Value: -1}}}, {Keys: bson.D{{Key: "kind", Value: 1}, {Key: "customerId", Value: 1}}}, }, - "messages": { - // ULID _id is time-ordered, so this serves history pagination. - {Keys: bson.D{{Key: "conversationId", Value: 1}, {Key: "_id", Value: 1}}}, - }, "notifications": { {Keys: bson.D{{Key: "userId", Value: 1}, {Key: "readAt", Value: 1}, {Key: "createdAt", Value: -1}}}, }, @@ -62,6 +58,14 @@ func EnsureIndexes(ctx context.Context, db *mongo.Database) error { {Keys: bson.D{{Key: "expiresAt", Value: 1}}, Options: options.Index().SetExpireAfterSeconds(0)}, {Keys: bson.D{{Key: "userId", Value: 1}}}, }, + "passwordResets": { + {Keys: bson.D{{Key: "expiresAt", Value: 1}}, Options: options.Index().SetExpireAfterSeconds(0)}, + }, + "messages": { + // ULID _id is time-ordered, so this serves history pagination. + {Keys: bson.D{{Key: "conversationId", Value: 1}, {Key: "_id", Value: 1}}}, + {Keys: bson.D{{Key: "attachments.fileId", Value: 1}}, Options: options.Index().SetSparse(true)}, + }, "auditLog": { {Keys: bson.D{{Key: "at", Value: -1}}}, {Keys: bson.D{{Key: "entityId", Value: 1}, {Key: "at", Value: -1}}}, diff --git a/internal/store/passwordresets.go b/internal/store/passwordresets.go new file mode 100644 index 0000000..70f2224 --- /dev/null +++ b/internal/store/passwordresets.go @@ -0,0 +1,45 @@ +package store + +import ( + "context" + "errors" + "fmt" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +// PasswordReset is a one-shot token (§11.22), reaped by TTL index. +type PasswordReset struct { + Token string `bson:"_id"` + UserID string `bson:"userId"` + ExpiresAt time.Time `bson:"expiresAt"` +} + +func (s *Store) CreatePasswordReset(ctx context.Context, token, userID string, ttl time.Duration) error { + _, err := s.DB.Collection("passwordResets").InsertOne(ctx, PasswordReset{ + Token: token, UserID: userID, ExpiresAt: time.Now().UTC().Add(ttl), + }) + if err != nil { + return fmt.Errorf("create password reset: %w", err) + } + return nil +} + +// ConsumePasswordReset returns the userId for a live token and deletes it +// atomically (single use). +func (s *Store) ConsumePasswordReset(ctx context.Context, token string) (string, error) { + var pr PasswordReset + err := s.DB.Collection("passwordResets").FindOneAndDelete(ctx, bson.M{ + "_id": token, + "expiresAt": bson.M{"$gt": time.Now().UTC()}, + }).Decode(&pr) + if errors.Is(err, mongo.ErrNoDocuments) { + return "", ErrNotFound + } + if err != nil { + return "", fmt.Errorf("consume password reset: %w", err) + } + return pr.UserID, nil +} diff --git a/scripts/seed.go b/scripts/seed.go new file mode 100644 index 0000000..d7d2daa --- /dev/null +++ b/scripts/seed.go @@ -0,0 +1,187 @@ +// Command seed populates a demo environment (§11.11): one demo customer +// (offline ticketing type), 1 admin, 2 consultants, 6 developers, pool +// memberships, and sample conversations. Idempotent: existing users (by +// email) and customers (by name) are reused, not duplicated. +// +// MONGO_SEED_URI=mongodb://127.0.0.1:27017 go run ./scripts +package main + +import ( + "context" + "fmt" + "log" + "os" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + + "bountyboard/internal/auth" + "bountyboard/internal/config" + "bountyboard/internal/domain" + "bountyboard/internal/store" + "bountyboard/internal/ulid" + + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +const demoPassword = "demo-pass-123" + +func main() { + _ = config.LoadDotEnv(".env") + uri := os.Getenv("MONGO_SEED_URI") + if uri == "" { + uri = "mongodb://127.0.0.1:27017" + } + dbName := os.Getenv("MONGO_DB") + if dbName == "" { + dbName = "bountyboard" + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + client, err := mongo.Connect(options.Client().ApplyURI(uri). + SetServerSelectionTimeout(5 * time.Second)) + if err != nil { + log.Fatalf("connect: %v", err) + } + defer client.Disconnect(context.Background()) + st := &store.Store{Client: client, DB: client.Database(dbName)} + if err := store.EnsureIndexes(ctx, st.DB); err != nil { + log.Fatalf("indexes: %v", err) + } + + user := func(email, name string, roles domain.Roles) *domain.User { + if u, err := st.UserByEmail(ctx, email); err == nil { + fmt.Printf(" exists: %s\n", email) + return u + } + hash, err := auth.HashPassword(demoPassword) + if err != nil { + log.Fatal(err) + } + u := &domain.User{ + ID: ulid.New(), Email: email, Name: name, Roles: roles, + Auth: domain.Auth{Local: &domain.LocalAuth{PasswordHash: hash}}, + Settings: domain.UserSettings{Theme: "light", + Notifications: domain.NotificationPrefs{Email: true, InApp: true}}, + Bio: "Seeded demo account.", + } + if err := st.CreateUser(ctx, u); err != nil { + log.Fatalf("create %s: %v", email, err) + } + fmt.Printf(" created: %s (password %q)\n", email, demoPassword) + return u + } + + fmt.Println("Seeding users…") + user("seed-admin@example.com", "Seed Admin", domain.Roles{Admin: true}) + clara := user("clara@example.com", "Clara Consultant", domain.Roles{Consultant: true}) + carlos := user("carlos@example.com", "Carlos Consultant", domain.Roles{Consultant: true}) + devs := []*domain.User{} + devNames := []string{"Dana", "Devon", "Dimitri", "Daria", "Dylan", "Drew"} + for i, n := range devNames { + devs = append(devs, user(fmt.Sprintf("dev%d@example.com", i+1), + n+" Developer", domain.Roles{Developer: true})) + } + + fmt.Println("Seeding pools…") + addPool := func(c, d *domain.User) { + if err := st.AddToPool(ctx, c.ID, d.ID); err != nil && err != store.ErrDuplicate { + log.Fatalf("pool: %v", err) + } + } + for _, d := range devs[:4] { + addPool(clara, d) + } + for _, d := range devs[2:] { + addPool(carlos, d) + } + + fmt.Println("Seeding demo customer…") + customers, err := st.ListCustomers(ctx, "", true) + if err != nil { + log.Fatal(err) + } + var demoCustomer *domain.Customer + for i := range customers { + if customers[i].Name == "ACME Demo" { + demoCustomer = &customers[i] + } + } + if demoCustomer == nil { + demoCustomer = &domain.Customer{ + Name: "ACME Demo", + Ticketing: domain.Ticketing{ + Type: domain.TicketingDemo, ProjectKey: "ACME", PollIntervalSec: 30, + }, + ConsultantIDs: []string{clara.ID, carlos.ID}, + DefaultBudget: 1500, + } + if err := st.CreateCustomer(ctx, demoCustomer); err != nil { + log.Fatal(err) + } + fmt.Println(" created: ACME Demo (demo ticketing — tickets appear within one poll)") + } else { + fmt.Println(" exists: ACME Demo") + } + + fmt.Println("Seeding conversations…") + dm, err := st.FindOrCreateDM(ctx, clara.ID, devs[0].ID) + if err != nil { + log.Fatal(err) + } + n, err := st.DB.Collection("messages").CountDocuments(ctx, bson.M{"conversationId": dm.ID}) + if err != nil { + log.Fatal(err) + } + if n == 0 { + msgs := []struct { + from *domain.User + body string + }{ + {clara, "

Hi Dana — I just added you to my pool. The ACME board fills up shortly.

"}, + {devs[0], "

Great, I will grab the CSV importer once it is published.

"}, + {clara, "

Perfect. Ping me here if any acceptance criteria are unclear.

"}, + } + for _, m := range msgs { + if err := st.InsertMessage(ctx, &store.Message{ + ConversationID: dm.ID, SenderID: m.from.ID, Body: m.body, + }); err != nil { + log.Fatal(err) + } + } + fmt.Println(" created: DM Clara ↔ Dana with 3 messages") + } + groups, _ := st.ListConversations(ctx, clara.ID) + hasGroup := false + for _, g := range groups { + if g.Kind == "group" && g.Title == "ACME standup" { + hasGroup = true + } + } + if !hasGroup { + grp := &store.Conversation{ + Kind: "group", Title: "ACME standup", + ParticipantIDs: []string{clara.ID, carlos.ID, devs[0].ID, devs[1].ID, devs[2].ID}, + } + if err := st.CreateConversation(ctx, grp); err != nil { + log.Fatal(err) + } + if err := st.InsertMessage(ctx, &store.Message{ + ConversationID: grp.ID, SenderID: carlos.ID, + Body: "

Welcome to the ACME standup channel. Daily updates here, please.

", + }); err != nil { + log.Fatal(err) + } + fmt.Println(" created: group 'ACME standup'") + } + + fmt.Println("\nSeed complete.") + fmt.Printf("Demo accounts (password %q):\n", demoPassword) + fmt.Println(" seed-admin@example.com (admin)") + fmt.Println(" clara@example.com, carlos@example.com (consultants)") + for i := range devNames { + fmt.Printf(" dev%d@example.com (developer)\n", i+1) + } +} diff --git a/scripts/smoke.sh b/scripts/smoke.sh index a70ace2..92d1461 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -1,28 +1,61 @@ #!/usr/bin/env bash -# Curl-based smoke test against a running stack (docker compose up -d). -# Grows with the system; later phases add register→login→board coverage. +# Curl-based smoke test (§13): register → login → healthz → board against a +# running stack (docker compose up -d). set -euo pipefail BASE_URL="${BASE_URL:-http://localhost:8787}" +JAR=$(mktemp) +trap 'rm -f "$JAR" /tmp/smoke-*.json' EXIT fail() { echo "SMOKE FAIL: $*" >&2; exit 1; } echo "smoke: $BASE_URL" -# healthz must always be 200 while the process is up +# 1. health endpoints code=$(curl -fsS -o /tmp/smoke-healthz.json -w '%{http_code}' "$BASE_URL/healthz") \ || fail "healthz unreachable" [ "$code" = "200" ] || fail "healthz returned $code" -grep -q '"ok"' /tmp/smoke-healthz.json || fail "healthz body unexpected: $(cat /tmp/smoke-healthz.json)" +grep -q '"ok"' /tmp/smoke-healthz.json || fail "healthz body unexpected" echo " healthz ok" -# readyz reflects dependency state; required deps must be up for the smoke run code=$(curl -sS -o /tmp/smoke-readyz.json -w '%{http_code}' "$BASE_URL/readyz") \ || fail "readyz unreachable" [ "$code" = "200" ] || fail "readyz returned $code: $(cat /tmp/smoke-readyz.json)" echo " readyz ok" -# metricsz exposes counters curl -fsS "$BASE_URL/metricsz" | grep -q '"counters"' || fail "metricsz body unexpected" echo " metricsz ok" +# 2. register a throwaway developer +EMAIL="smoke-$(date +%s)-$RANDOM@example.com" +code=$(curl -sS -c "$JAR" -o /tmp/smoke-reg.json -w '%{http_code}' \ + -H 'Content-Type: application/json' \ + -d "{\"email\":\"$EMAIL\",\"name\":\"Smoke Test\",\"password\":\"smoke-pass-123\"}" \ + "$BASE_URL/api/v1/auth/register") +[ "$code" = "201" ] || fail "register returned $code: $(cat /tmp/smoke-reg.json)" +echo " register ok ($EMAIL)" + +# 3. logout, then login again +CSRF=$(awk '$6=="bb_csrf" {print $7}' "$JAR") +curl -fsS -b "$JAR" -X POST -H "X-CSRF-Token: $CSRF" \ + "$BASE_URL/api/v1/auth/logout" -o /dev/null || fail "logout failed" +code=$(curl -sS -c "$JAR" -o /tmp/smoke-login.json -w '%{http_code}' \ + -H 'Content-Type: application/json' \ + -d "{\"email\":\"$EMAIL\",\"password\":\"smoke-pass-123\"}" \ + "$BASE_URL/api/v1/auth/login") +[ "$code" = "200" ] || fail "login returned $code" +echo " login ok" + +# 4. authenticated me + developer board +curl -fsS -b "$JAR" "$BASE_URL/api/v1/auth/me" | grep -q "$EMAIL" || fail "me missing email" +echo " me ok" +code=$(curl -sS -b "$JAR" -o /tmp/smoke-board.json -w '%{http_code}' "$BASE_URL/api/v1/board") +[ "$code" = "200" ] || fail "board returned $code: $(cat /tmp/smoke-board.json)" +grep -q '"tasks"' /tmp/smoke-board.json || fail "board body unexpected" +echo " board ok" + +# 5. pages render +curl -fsS "$BASE_URL/login" | grep -q 'Log in · Bounty Board' || fail "login page broken" +curl -fsS "$BASE_URL/api/docs" | grep -q 'Bounty Board API' || fail "api docs broken" +echo " pages ok" + echo "SMOKE PASS" diff --git a/web/static/css/app.css b/web/static/css/app.css index 7e0c005..6eecb36 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -155,6 +155,11 @@ table.list th { color: var(--muted); font-size: 0.875rem; } .toast.err { border-left-color: var(--err); } .toast.ok { border-left-color: var(--ok); } +.hovercard { + position: absolute; z-index: 90; width: 300px; + box-shadow: 0 4px 16px rgba(0,0,0,0.25); +} + /* chat */ .chat-layout { display: grid; grid-template-columns: 280px 1fr; gap: 16px; min-height: 70vh; } .chat-sidebar { overflow: auto; } diff --git a/web/static/js/forgot-password.js b/web/static/js/forgot-password.js new file mode 100644 index 0000000..2e85500 --- /dev/null +++ b/web/static/js/forgot-password.js @@ -0,0 +1,19 @@ +import { api } from '/static/js/api.js'; + +document.getElementById('forgot-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const errorBox = document.getElementById('error'); + const okBox = document.getElementById('ok'); + errorBox.textContent = ''; + okBox.textContent = ''; + try { + await api('POST', '/api/v1/auth/forgot', { + email: document.getElementById('email').value.trim(), + }); + okBox.textContent = 'If an account with that email exists, a reset link is on its way.'; + } catch (err) { + errorBox.textContent = err.code === 'smtp_disabled' + ? 'Email is not configured on this server — ask an administrator to reset your password.' + : err.message; + } +}); diff --git a/web/static/js/reset-password.js b/web/static/js/reset-password.js new file mode 100644 index 0000000..aa9c2c7 --- /dev/null +++ b/web/static/js/reset-password.js @@ -0,0 +1,19 @@ +import { api } from '/static/js/api.js'; + +document.getElementById('reset-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const errorBox = document.getElementById('error'); + errorBox.textContent = ''; + const pw = document.getElementById('new').value; + if (pw !== document.getElementById('confirm').value) { + errorBox.textContent = 'Passwords do not match.'; + return; + } + try { + await api('POST', '/api/v1/auth/reset', { + token: new URLSearchParams(window.location.search).get('token') || '', + newPassword: pw, + }); + window.location.href = '/login'; + } catch (err) { errorBox.textContent = err.message; } +}); diff --git a/web/static/js/shortcuts.js b/web/static/js/shortcuts.js new file mode 100644 index 0000000..a61e3a0 --- /dev/null +++ b/web/static/js/shortcuts.js @@ -0,0 +1,83 @@ +// Global keyboard shortcuts (§10): g b → board, g m → messages, / → search. +// Plus profile hover cards (§11.13) for any [data-user-card] element. +import { api } from '/static/js/api.js'; + +let pendingG = false; +let gTimer = null; + +function typingTarget(e) { + const t = e.target; + return t.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(t.tagName); +} + +document.addEventListener('keydown', (e) => { + if (typingTarget(e) || e.ctrlKey || e.metaKey || e.altKey) return; + if (e.key === '/') { + const search = document.querySelector('input[type=search]'); + if (search) { e.preventDefault(); search.focus(); } + return; + } + if (pendingG) { + pendingG = false; + clearTimeout(gTimer); + const map = { b: '/board', m: '/messages', h: '/', t: '/my-tasks', a: '/consultant/board' }; + if (map[e.key]) { e.preventDefault(); window.location.href = map[e.key]; } + return; + } + if (e.key === 'g') { + pendingG = true; + gTimer = setTimeout(() => { pendingG = false; }, 1200); + } +}); + +// ---- hover cards ---- +let cardEl = null; +let hideTimer = null; +const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', +}[c])); + +function hideCard() { + if (cardEl) { cardEl.remove(); cardEl = null; } +} + +async function showCard(target, userId) { + try { + const res = await api('GET', `/api/v1/users/${userId}/card`); + const c = res.card; + hideCard(); + cardEl = document.createElement('div'); + cardEl.className = 'hovercard card'; + const roles = ['admin', 'consultant', 'developer'].filter((r) => c.roles[r]); + cardEl.innerHTML = ` +
+ ${c.avatarFileId ? `` + : `${esc(c.name.slice(0, 1).toUpperCase())}`} +
+ ${esc(c.name)}
+ ${roles.map((r) => `${r}`).join(' ')} +
+
+ ${c.bio ? `

${esc(c.bio.slice(0, 160))}

` : ''} + ${c.contact && c.contact.location ? `

📍 ${esc(c.contact.location)}

` : ''}`; + document.body.appendChild(cardEl); + const rect = target.getBoundingClientRect(); + cardEl.style.left = Math.min(rect.left, window.innerWidth - 320) + 'px'; + cardEl.style.top = (rect.bottom + window.scrollY + 6) + 'px'; + cardEl.addEventListener('mouseenter', () => clearTimeout(hideTimer)); + cardEl.addEventListener('mouseleave', () => { hideTimer = setTimeout(hideCard, 200); }); + } catch (e) { /* card is best-effort */ } +} + +document.addEventListener('mouseover', (e) => { + const target = e.target.closest('[data-user-card]'); + if (!target) return; + clearTimeout(hideTimer); + hideTimer = setTimeout(() => showCard(target, target.dataset.userCard), 350); +}); +document.addEventListener('mouseout', (e) => { + if (e.target.closest('[data-user-card]')) { + clearTimeout(hideTimer); + hideTimer = setTimeout(hideCard, 250); + } +}); diff --git a/web/templates/forgot-password.html b/web/templates/forgot-password.html new file mode 100644 index 0000000..83a8c6e --- /dev/null +++ b/web/templates/forgot-password.html @@ -0,0 +1,17 @@ +{{define "content"}} +
+

Forgot password

+ +
+
+
+ + +
+
+ + Back to log in +
+
+
+{{end}} diff --git a/web/templates/layout.html b/web/templates/layout.html index f3e878b..1f6ce6a 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -57,7 +57,8 @@
- {{if .User}}{{end}} + {{if .User}} + {{end}} {{range .Scripts}} {{end}} diff --git a/web/templates/login.html b/web/templates/login.html index b4857f0..91c08df 100644 --- a/web/templates/login.html +++ b/web/templates/login.html @@ -13,7 +13,7 @@ {{if .OIDCEnabled}} diff --git a/web/templates/reset-password.html b/web/templates/reset-password.html new file mode 100644 index 0000000..2b76d3a --- /dev/null +++ b/web/templates/reset-password.html @@ -0,0 +1,17 @@ +{{define "content"}} +
+

Set a new password

+ +
+
+ + +
+
+ + +
+ +
+
+{{end}}