feat: seed script, forgot-password, hover cards, shortcuts, OpenAPI docs, runbook (phase 12)
- scripts/seed.go: idempotent demo data per §11.11 (make seed) - forgot/reset password: SMTP-gated, one-shot TTL tokens, uniform responses against enumeration, sessions revoked on reset; login page link + pages - profile hover cards on [data-user-card] elements (§11.13) - keyboard shortcuts: g b/m/t/h navigation, / focuses search (§10) - bulk archive endpoint (§11.9) - hand-written OpenAPI 3.1 covering §6, served at /api/docs + yaml download - make backup / make restore (mongodump archive via the mongo container) - README: quick start, demo data, runbook, breaker/job operations, working Caddy + nginx reverse-proxy samples (WS block, client_max_body_size), documented later-stubs (§11.24) - smoke.sh now exercises register → logout → login → me → board → pages Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,14 +29,20 @@ lint:
|
|||||||
echo "gofmt needed on:"; echo "$$unformatted"; exit 1; fi
|
echo "gofmt needed on:"; echo "$$unformatted"; exit 1; fi
|
||||||
$(GO) vet ./...
|
$(GO) vet ./...
|
||||||
|
|
||||||
|
# Requires Mongo published on loopback (test overlay) or MONGO_SEED_URI.
|
||||||
seed:
|
seed:
|
||||||
@echo "seed script lands in phase 12 (scripts/seed.go)"; exit 1
|
$(GO) run ./scripts
|
||||||
|
|
||||||
smoke:
|
smoke:
|
||||||
./scripts/smoke.sh
|
./scripts/smoke.sh
|
||||||
|
|
||||||
|
# mongodump/mongorestore through the mongo container into ./backups (§11.23)
|
||||||
backup:
|
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:
|
restore:
|
||||||
@echo "restore target lands in phase 12"; exit 1
|
@test -n "$(FILE)" || (echo "usage: make restore FILE=backups/<archive>"; exit 1)
|
||||||
|
docker compose exec -T mongo mongorestore --archive --drop < $(FILE)
|
||||||
|
|||||||
@@ -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 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 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 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.
|
||||||
|
|||||||
@@ -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=<something strong>
|
||||||
|
# ATOMIZER_TOKEN / WORK_PERFORMER_TOKEN = random strings
|
||||||
|
# ANTHROPIC_API_KEY=<your 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-<ts>.archive
|
||||||
|
make restore FILE=backups/<archive> # 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.
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Package api embeds the hand-written OpenAPI document (§6).
|
||||||
|
package api
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
//go:embed openapi.yaml
|
||||||
|
var OpenAPI []byte
|
||||||
@@ -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=<ulid>`.
|
||||||
|
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}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.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/login", s.handleOIDCLogin)
|
||||||
mux.HandleFunc("GET /api/v1/auth/oidc/callback", s.handleOIDCCallback)
|
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.
|
// decodeJSON reads a bounded JSON body into dst, rejecting unknown fields.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type Server struct {
|
|||||||
files *files.Store
|
files *files.Store
|
||||||
templates map[string]*template.Template
|
templates map[string]*template.Template
|
||||||
oidc *auth.OIDCClient
|
oidc *auth.OIDCClient
|
||||||
|
mailer *auth.Mailer
|
||||||
loginLimiter *auth.RateLimiter
|
loginLimiter *auth.RateLimiter
|
||||||
checks []ReadinessCheck
|
checks []ReadinessCheck
|
||||||
startedAt time.Time
|
startedAt time.Time
|
||||||
@@ -72,6 +73,7 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.
|
|||||||
files: fs,
|
files: fs,
|
||||||
templates: templates,
|
templates: templates,
|
||||||
oidc: auth.NewOIDCClient(cfg, log),
|
oidc: auth.NewOIDCClient(cfg, log),
|
||||||
|
mailer: auth.NewMailer(cfg.SMTP),
|
||||||
loginLimiter: auth.NewRateLimiter(10, 15*time.Minute),
|
loginLimiter: auth.NewRateLimiter(10, 15*time.Minute),
|
||||||
startedAt: time.Now(),
|
startedAt: time.Now(),
|
||||||
}
|
}
|
||||||
@@ -89,6 +91,7 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.
|
|||||||
s.routesWorkResults(mux)
|
s.routesWorkResults(mux)
|
||||||
s.routesMessages(mux)
|
s.routesMessages(mux)
|
||||||
s.routesMetrics(mux)
|
s.routesMetrics(mux)
|
||||||
|
s.routesDocs(mux)
|
||||||
mux.HandleFunc("GET /ws", s.handleWS)
|
mux.HandleFunc("GET /ws", s.handleWS)
|
||||||
s.routesWeb(mux)
|
s.routesWeb(mux)
|
||||||
|
|
||||||
|
|||||||
@@ -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/{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/publish", s.authedRole("consultant", s.handlePublishBulk))
|
||||||
mux.Handle("POST /api/v1/tasks/{id}/archive", s.authed(s.handleArchiveTask))
|
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/tasks/{id}", s.requireAuth(http.HandlerFunc(s.handleTaskDetail)))
|
||||||
mux.Handle("GET /api/v1/service-health", s.requireAuth(http.HandlerFunc(s.handleServiceHealth)))
|
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)
|
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;
|
// handleTaskDetail is role-scoped: admins and customer consultants always;
|
||||||
// developers when they are the assignee, have a claim, or the task is on the
|
// developers when they are the assignee, have a claim, or the task is on the
|
||||||
// public board (published/claim_requested).
|
// public board (published/claim_requested).
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ package httpx
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"bountyboard/api"
|
||||||
"bountyboard/internal/domain"
|
"bountyboard/internal/domain"
|
||||||
"bountyboard/web"
|
"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) {
|
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{
|
s.render(w, r, "change-password.html", &pageData{
|
||||||
Title: "Change password", User: u, Narrow: true,
|
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)
|
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(`<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||||
|
<title>Bounty Board API</title><link rel="stylesheet" href="/static/css/app.css">
|
||||||
|
<script src="/static/js/theme.js"></script></head>
|
||||||
|
<body><main class="container"><h1>Bounty Board API</h1>
|
||||||
|
<p><a class="btn" href="/api/openapi.yaml" download>Download openapi.yaml</a></p>
|
||||||
|
<pre style="white-space:pre-wrap;background:var(--surface);border:1px solid var(--border);padding:16px;border-radius:var(--radius)">` +
|
||||||
|
html.EscapeString(string(api.OpenAPI)) + `</pre></main></body></html>`))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func loginErrorMessage(code string) string {
|
func loginErrorMessage(code string) string {
|
||||||
switch code {
|
switch code {
|
||||||
case "":
|
case "":
|
||||||
|
|||||||
@@ -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: "participantIds", Value: 1}, {Key: "lastMessageAt", Value: -1}}},
|
||||||
{Keys: bson.D{{Key: "kind", Value: 1}, {Key: "customerId", 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": {
|
"notifications": {
|
||||||
{Keys: bson.D{{Key: "userId", Value: 1}, {Key: "readAt", Value: 1}, {Key: "createdAt", Value: -1}}},
|
{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: "expiresAt", Value: 1}}, Options: options.Index().SetExpireAfterSeconds(0)},
|
||||||
{Keys: bson.D{{Key: "userId", Value: 1}}},
|
{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": {
|
"auditLog": {
|
||||||
{Keys: bson.D{{Key: "at", Value: -1}}},
|
{Keys: bson.D{{Key: "at", Value: -1}}},
|
||||||
{Keys: bson.D{{Key: "entityId", Value: 1}, {Key: "at", Value: -1}}},
|
{Keys: bson.D{{Key: "entityId", Value: 1}, {Key: "at", Value: -1}}},
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+187
@@ -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, "<p>Hi Dana — I just added you to my pool. The ACME board fills up shortly.</p>"},
|
||||||
|
{devs[0], "<p>Great, I will grab the <b>CSV importer</b> once it is published.</p>"},
|
||||||
|
{clara, "<p>Perfect. Ping me here if any acceptance criteria are unclear.</p>"},
|
||||||
|
}
|
||||||
|
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: "<p>Welcome to the ACME standup channel. Daily updates here, please.</p>",
|
||||||
|
}); 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-6
@@ -1,28 +1,61 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Curl-based smoke test against a running stack (docker compose up -d).
|
# Curl-based smoke test (§13): register → login → healthz → board against a
|
||||||
# Grows with the system; later phases add register→login→board coverage.
|
# running stack (docker compose up -d).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
BASE_URL="${BASE_URL:-http://localhost:8787}"
|
BASE_URL="${BASE_URL:-http://localhost:8787}"
|
||||||
|
JAR=$(mktemp)
|
||||||
|
trap 'rm -f "$JAR" /tmp/smoke-*.json' EXIT
|
||||||
fail() { echo "SMOKE FAIL: $*" >&2; exit 1; }
|
fail() { echo "SMOKE FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
echo "smoke: $BASE_URL"
|
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") \
|
code=$(curl -fsS -o /tmp/smoke-healthz.json -w '%{http_code}' "$BASE_URL/healthz") \
|
||||||
|| fail "healthz unreachable"
|
|| fail "healthz unreachable"
|
||||||
[ "$code" = "200" ] || fail "healthz returned $code"
|
[ "$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"
|
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") \
|
code=$(curl -sS -o /tmp/smoke-readyz.json -w '%{http_code}' "$BASE_URL/readyz") \
|
||||||
|| fail "readyz unreachable"
|
|| fail "readyz unreachable"
|
||||||
[ "$code" = "200" ] || fail "readyz returned $code: $(cat /tmp/smoke-readyz.json)"
|
[ "$code" = "200" ] || fail "readyz returned $code: $(cat /tmp/smoke-readyz.json)"
|
||||||
echo " readyz ok"
|
echo " readyz ok"
|
||||||
|
|
||||||
# metricsz exposes counters
|
|
||||||
curl -fsS "$BASE_URL/metricsz" | grep -q '"counters"' || fail "metricsz body unexpected"
|
curl -fsS "$BASE_URL/metricsz" | grep -q '"counters"' || fail "metricsz body unexpected"
|
||||||
echo " metricsz ok"
|
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"
|
echo "SMOKE PASS"
|
||||||
|
|||||||
@@ -155,6 +155,11 @@ table.list th { color: var(--muted); font-size: 0.875rem; }
|
|||||||
.toast.err { border-left-color: var(--err); }
|
.toast.err { border-left-color: var(--err); }
|
||||||
.toast.ok { border-left-color: var(--ok); }
|
.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 */
|
||||||
.chat-layout { display: grid; grid-template-columns: 280px 1fr; gap: 16px; min-height: 70vh; }
|
.chat-layout { display: grid; grid-template-columns: 280px 1fr; gap: 16px; min-height: 70vh; }
|
||||||
.chat-sidebar { overflow: auto; }
|
.chat-sidebar { overflow: auto; }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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; }
|
||||||
|
});
|
||||||
@@ -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 = `
|
||||||
|
<div class="spread">
|
||||||
|
${c.avatarFileId ? `<img class="avatar large" src="/files/${c.avatarFileId}" alt="">`
|
||||||
|
: `<span class="avatar large">${esc(c.name.slice(0, 1).toUpperCase())}</span>`}
|
||||||
|
<div>
|
||||||
|
<strong>${esc(c.name)}</strong><br>
|
||||||
|
${roles.map((r) => `<span class="badge">${r}</span>`).join(' ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${c.bio ? `<p class="muted">${esc(c.bio.slice(0, 160))}</p>` : ''}
|
||||||
|
${c.contact && c.contact.location ? `<p class="muted">📍 ${esc(c.contact.location)}</p>` : ''}`;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="card">
|
||||||
|
<h1>Forgot password</h1>
|
||||||
|
<div class="error-box" id="error" role="alert"></div>
|
||||||
|
<div class="ok-box" id="ok" role="status"></div>
|
||||||
|
<form id="forgot-form">
|
||||||
|
<div class="field">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" id="email" autocomplete="email" required>
|
||||||
|
</div>
|
||||||
|
<div class="spread">
|
||||||
|
<button class="btn primary" type="submit">Send reset link</button>
|
||||||
|
<a href="/login">Back to log in</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -57,7 +57,8 @@
|
|||||||
</main>
|
</main>
|
||||||
<div id="toasts" aria-live="polite"></div>
|
<div id="toasts" aria-live="polite"></div>
|
||||||
<script type="module" src="/static/js/nav.js"></script>
|
<script type="module" src="/static/js/nav.js"></script>
|
||||||
{{if .User}}<script type="module" src="/static/js/notifications.js"></script>{{end}}
|
{{if .User}}<script type="module" src="/static/js/notifications.js"></script>
|
||||||
|
<script type="module" src="/static/js/shortcuts.js"></script>{{end}}
|
||||||
{{range .Scripts}}<script type="module" src="{{.}}"></script>
|
{{range .Scripts}}<script type="module" src="{{.}}"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="spread">
|
<div class="spread">
|
||||||
<button class="btn primary" type="submit">Log in</button>
|
<button class="btn primary" type="submit">Log in</button>
|
||||||
<a href="/register">Create an account</a>
|
<span><a href="/forgot-password">Forgot password?</a> · <a href="/register">Create an account</a></span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{{if .OIDCEnabled}}
|
{{if .OIDCEnabled}}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="card">
|
||||||
|
<h1>Set a new password</h1>
|
||||||
|
<div class="error-box" id="error" role="alert"></div>
|
||||||
|
<form id="reset-form">
|
||||||
|
<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">Set password</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user