Add user accounts with first-run onboarding and per-user preferences
Replaces the optional shared-password auth (AUTH_PASSWORD env var, in-memory sessions) with account-based auth: a users table (scrypt password hashes via node:crypto — no native dep), SQLite-backed sessions that survive restarts, a one-time setup screen that creates the first account, and a Settings → Users card for adding/removing users and resetting passwords (everyone equal, shared herd — no roles). Theme and weight-unit become per-user preferences behind the unchanged /api/settings shape (NULL inherits the global value); rabbitry name, kit prefix, and default breed stay shared. Failed logins get a flat 350 ms delay and timing-safe comparison. MYKINDLEBOX_PASSWORD is retired — upgrading deployments see the setup screen once, data untouched. Also: configurable host port (MYKINDLEBOX_PORT), compose healthcheck gating nginx on API readiness, nginx body-size headroom for 10 MB imports, server .dockerignore hardening, ESLint ignore for vendored tesseract assets, react-hooks rule fixes, exact tesseract.js pin with SW cache-upgrade contract and failure-isolated precache, and OCR date parsing fixes (2-digit-year pivot, impossible-date rejection). Design doc: memory-bank/FEATURE_User_Accounts.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
1368dc5ec4
commit
2a0e347a19
|
|
@ -30,7 +30,7 @@ Frontend (`mykindlebox/client`):
|
||||||
- `npm run build` — production build to `dist/`
|
- `npm run build` — production build to `dist/`
|
||||||
- `npm run lint` — ESLint
|
- `npm run lint` — ESLint
|
||||||
|
|
||||||
Deployment: `docker compose up --build` from `mykindlebox/` serves the app on port 8080 by default (nginx serves the built client and reverse-proxies `/api` + `/uploads` to the Node server). Configure via `mykindlebox/.env`: `MYKINDLEBOX_PORT` overrides the host port, and `MYKINDLEBOX_PASSWORD` requires a login (if unset, auth is disabled).
|
Deployment: `docker compose up --build` from `mykindlebox/` serves the app on port 8080 by default (nginx serves the built client and reverse-proxies `/api` + `/uploads` to the Node server). `MYKINDLEBOX_PORT` in `mykindlebox/.env` overrides the host port. Auth is account-based (first visit shows a one-time account-creation screen; more users added in Settings) — the old `MYKINDLEBOX_PASSWORD`/`AUTH_PASSWORD` env var is retired and ignored.
|
||||||
|
|
||||||
### Conventions
|
### Conventions
|
||||||
|
|
||||||
|
|
@ -39,7 +39,7 @@ Deployment: `docker compose up --build` from `mykindlebox/` serves the app on po
|
||||||
|
|
||||||
## What MyKindlebox is
|
## What MyKindlebox is
|
||||||
|
|
||||||
A self-hosted rabbitry (rabbit breeding herd) management web app, replacing an existing PHP app (`belowland.com/rabbitsite`). It is single-user, self-hosted, and out of scope for v1 includes multi-tenancy, public listing pages, show tracking, and payments.
|
A self-hosted rabbitry (rabbit breeding herd) management web app, replacing an existing PHP app (`belowland.com/rabbitsite`). It is self-hosted with **shared-herd user accounts** (users are separate logins to the same rabbitry — see `memory-bank/FEATURE_User_Accounts.md`; everyone equal, no roles). Out of scope: multi-tenancy (per-user herds), public listing pages, and payments.
|
||||||
|
|
||||||
The core differentiator: **adding a rabbit starts from a pedigree tree, not a blank form** — the 3-generation ancestor tree (sire/dam, their sires/dams) is the primary data-entry surface, with inline autocomplete search against existing herd records and graceful fallback to freeform text for unknown/bought-in ancestors.
|
The core differentiator: **adding a rabbit starts from a pedigree tree, not a blank form** — the 3-generation ancestor tree (sire/dam, their sires/dams) is the primary data-entry surface, with inline autocomplete search against existing herd records and graceful fallback to freeform text for unknown/bought-in ancestors.
|
||||||
|
|
||||||
|
|
@ -53,7 +53,7 @@ The core differentiator: **adding a rabbit starts from a pedigree tree, not a bl
|
||||||
| Styling | Tailwind CSS with custom theme tokens |
|
| Styling | Tailwind CSS with custom theme tokens |
|
||||||
| Backend | Node.js + Express (REST API) |
|
| Backend | Node.js + Express (REST API) |
|
||||||
| Database | SQLite via Drizzle ORM (single-file DB) |
|
| Database | SQLite via Drizzle ORM (single-file DB) |
|
||||||
| Auth | Single-user session token (no OAuth) |
|
| Auth | User accounts, shared herd (scrypt + SQLite sessions, no OAuth) |
|
||||||
| Hosting | Docker Compose (Nginx + Node + SQLite volume) |
|
| Hosting | Docker Compose (Nginx + Node + SQLite volume) |
|
||||||
|
|
||||||
## Data model
|
## Data model
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
# Feature: User Accounts & Onboarding
|
||||||
|
|
||||||
|
Replaces the single shared-password auth (`AUTH_PASSWORD` env var, in-memory
|
||||||
|
session Set) with real user accounts. Decided with the user on 2026-07-02.
|
||||||
|
|
||||||
|
## Scope decisions (user-confirmed)
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Data scope | **Shared herd** — users are separate logins to the same rabbitry. No ownership columns on rabbits/litters/shows; nothing about the data model changes. |
|
||||||
|
| Account creation | **In-app, no self-registration** — a fresh install shows a one-time "create your account" setup screen; after that, any signed-in user can add more users from Settings. No email/invite infrastructure. |
|
||||||
|
| Roles | **Everyone equal** — no admin flag. Any user can manage users and settings. (Trust model: family/partners on a LAN.) |
|
||||||
|
| Onboarding extras | **Per-user preferences only** — `theme` and `weight_unit` become per-user; `rabbitry_name`, `kit_prefix`, `default_breed` (and the internal `kit_counter`) stay global. No first-run settings wizard, no guided tour. |
|
||||||
|
|
||||||
|
## Consequences to be aware of
|
||||||
|
|
||||||
|
- **Auth becomes mandatory.** Today, unset `AUTH_PASSWORD` = open access. After
|
||||||
|
this feature, a fresh install requires creating an account, and every request
|
||||||
|
requires a session. The "no password = open" mode is retired.
|
||||||
|
- **`MYKINDLEBOX_PASSWORD` / `AUTH_PASSWORD` is retired.** Removed from
|
||||||
|
docker-compose and the README. An existing deployment that upgrades sees the
|
||||||
|
setup screen on next visit (its `users` table is empty); herd data is
|
||||||
|
untouched. The old env var is simply ignored.
|
||||||
|
- Because you must be signed in to delete a user and you **cannot delete
|
||||||
|
yourself**, the last account can never be deleted — no lockout state exists.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
```
|
||||||
|
users:
|
||||||
|
id integer PK autoincrement
|
||||||
|
username text NOT NULL UNIQUE COLLATE NOCASE
|
||||||
|
password_hash text NOT NULL -- "scrypt:<salt hex>:<hash hex>"
|
||||||
|
theme text -- NULL = inherit global settings value
|
||||||
|
weight_unit text -- NULL = inherit global settings value
|
||||||
|
created_at text NOT NULL default current_timestamp
|
||||||
|
|
||||||
|
auth_sessions:
|
||||||
|
token text PK -- randomUUID
|
||||||
|
user_id integer NOT NULL FK -> users.id ON DELETE CASCADE
|
||||||
|
expires_at text NOT NULL -- ISO; 30 days from login
|
||||||
|
created_at text NOT NULL default current_timestamp
|
||||||
|
```
|
||||||
|
|
||||||
|
Sessions move from the in-memory Set to SQLite so a server restart/redeploy no
|
||||||
|
longer signs everyone out. Expired rows are deleted lazily whenever a session
|
||||||
|
check touches them. Deleting a user cascades their sessions (they're signed out
|
||||||
|
everywhere immediately).
|
||||||
|
|
||||||
|
## Password hashing
|
||||||
|
|
||||||
|
`node:crypto` `scryptSync` (N=16384 default params), 16-byte random salt,
|
||||||
|
64-byte key, stored as `scrypt:<salt>:<hash>`; verified with `timingSafeEqual`.
|
||||||
|
Deliberately **not** bcrypt/argon2 — no new native dependency, so the Alpine
|
||||||
|
Docker build stays exactly as it is. Failed logins get a flat ~350 ms delay as
|
||||||
|
cheap brute-force damping.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- `GET /api/auth/status` → `{ configured, authenticated, user? }` —
|
||||||
|
`configured` now means "at least one user exists". `user` = `{ id, username }`
|
||||||
|
when authenticated.
|
||||||
|
- `POST /api/auth/setup` `{ username, password }` — creates the **first** user
|
||||||
|
and signs them in; 409 once any user exists. This is the onboarding endpoint.
|
||||||
|
- `POST /api/auth/login` `{ username, password }` (username case-insensitive).
|
||||||
|
- `POST /api/auth/logout` — deletes the session row.
|
||||||
|
- `GET /api/users` → `[{ id, username, created_at }]` (no hashes).
|
||||||
|
- `POST /api/users` `{ username, password }`.
|
||||||
|
- `PATCH /api/users/:id` `{ password }` — reset any user's password (everyone
|
||||||
|
equal; family trust model — also the "someone forgot theirs" path).
|
||||||
|
- `DELETE /api/users/:id` — 400 when `id` is the caller.
|
||||||
|
- **`/api/settings` keeps its exact shape**: GET merges the global rows with the
|
||||||
|
current user's non-NULL `theme`/`weight_unit` overrides; PATCH routes those two
|
||||||
|
keys to the user row and everything else to the settings table. Client
|
||||||
|
consumers (ThemeSync, useWeightUnit, Settings page) need no changes for prefs.
|
||||||
|
|
||||||
|
## Client
|
||||||
|
|
||||||
|
- `AuthProvider` gains a third gate: `configured: false` → `SetupScreen`
|
||||||
|
(create first account), else unauthenticated → `LoginScreen` (now
|
||||||
|
username + password), else app. Context exposes `{ user, logout }`.
|
||||||
|
- Settings gets a **Users** card: list users, add user, reset password, delete
|
||||||
|
(self-delete disabled). The Account card shows who is signed in.
|
||||||
|
- Password minimum: 8 chars, enforced on both setup and user creation.
|
||||||
|
|
||||||
|
## Out of scope (deliberate)
|
||||||
|
|
||||||
|
Roles/permissions, invite links, email anything, per-user data visibility,
|
||||||
|
password-strength meters, 2FA, audit logs.
|
||||||
|
|
@ -19,7 +19,7 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
|
||||||
|---|---|
|
|---|---|
|
||||||
| `index.ts` | Entry point. Creates the Express app, applies `cors()` (allow-all — added in Phase 4 Step 4.3 so the Vite dev server on port 5173 can call this API cross-origin; revisit with a stricter allowlist once auth exists) and `express.json()`, imports `db/client` (so the DB connection/file is initialized on startup), mounts all routers, and starts listening on port 3001. |
|
| `index.ts` | Entry point. Creates the Express app, applies `cors()` (allow-all — added in Phase 4 Step 4.3 so the Vite dev server on port 5173 can call this API cross-origin; revisit with a stricter allowlist once auth exists) and `express.json()`, imports `db/client` (so the DB connection/file is initialized on startup), mounts all routers, and starts listening on port 3001. |
|
||||||
| `db/client.ts` | Opens the SQLite connection and exports the Drizzle `db` instance used by all query code. Ensures `server/data/` exists before opening the file — `better-sqlite3` will not create missing parent directories on its own. The DB file lives at `server/data/mykindlebox.db`. On startup it now (Step 2.5) calls `drizzle-orm/better-sqlite3/migrator`'s `migrate()` to apply any pending migrations, then seeds the two default `settings` rows (`rabbitry_name`, `weight_unit`) via raw `INSERT OR IGNORE` keyed on the `key` primary key — idempotent across restarts. Also exports `dataDir` and `uploadsDir` (`data/uploads/`, created here) so other modules (`routes/rabbits.ts`'s multer config, `index.ts`'s static file serving) share one canonical path instead of each recomputing it. |
|
| `db/client.ts` | Opens the SQLite connection and exports the Drizzle `db` instance used by all query code. Ensures `server/data/` exists before opening the file — `better-sqlite3` will not create missing parent directories on its own. The DB file lives at `server/data/mykindlebox.db`. On startup it now (Step 2.5) calls `drizzle-orm/better-sqlite3/migrator`'s `migrate()` to apply any pending migrations, then seeds the two default `settings` rows (`rabbitry_name`, `weight_unit`) via raw `INSERT OR IGNORE` keyed on the `key` primary key — idempotent across restarts. Also exports `dataDir` and `uploadsDir` (`data/uploads/`, created here) so other modules (`routes/rabbits.ts`'s multer config, `index.ts`'s static file serving) share one canonical path instead of each recomputing it. |
|
||||||
| `db/schema.ts` | Drizzle schema definitions: `rabbits` (24 base fields incl. `acquired_date`/`death_date` — the two timeline anchors that aren't derivable, see the rabbit-timeline note below; 7 genetics loci columns, 14 chain-authority `_name_text` ancestor columns; `sex`/`status` enforced via SQLite `CHECK` constraints, not just TS union types; `sire_id`/`dam_id` self-referencing FKs), `litters` (`sire_id`/`dam_id` FKs to `rabbits.id`, `breed_date`, `kindle_date`, `kit_count`, `notes`, `created_at` — a litter row spans the breeding→kindle lifecycle: `breed_date` is set when a breeding is recorded, `kindle_date` is nullable until the doe kindles; at least one of the two is required, enforced in the route), `rabbit_events` (`rabbit_id` FK with `ON DELETE cascade`, `type` (`show`/`sale`/`other`, CHECK-constrained), `event_date`/`title` notNull, `notes`, plus `show_class`/`placement` which are only meaningful for `type='show'` — the manually-logged events behind the rabbit timeline; see the rabbit-timeline note below), and `settings` (`key` text primary key, `value` text — simple key-value store, not a typed config object). The `rabbits` table also carries a nullable `breed` column (added for the Shows feature's entries/report, but surfaced everywhere a rabbit is edited/displayed). `arba_shows_cache` (`id`, `fetched_at`, `data` — a JSON blob of the normalized ARBA show list; only ever one row, the latest scrape; see `lib/arba.ts` + `routes/shows.ts`). `show_events` (`name` notNull, `notes`, `created_at`/`updated_at` — a user-created grouping of ARBA shows; **no `user_id`**, single-user app) and `show_event_shows` (`show_event_id` FK with `ON DELETE cascade`, plus a **snapshot** of the ARBA row at add-time: `sanction_number`/`club_name` notNull, `show_date`/`show_type`/`specialty_breed`/`city`/`state`/`address`/`secretary`/`secretary_email`/`catalog_url`/`maps_url`/`display_order` — copied rather than referencing the cache because ARBA listings are transient). `show_event_entries` (a herd rabbit entered into a specific show: `show_event_show_id` FK `ON DELETE cascade`, `rabbit_id` FK to `rabbits` **without** cascade, `entry_class`/`entry_number`/`notes` free text, `created_at`). `show_event_judges` (`show_event_show_id` cascade FK, `judge_name` notNull, `judge_arba_number`/`notes`). `show_event_placements` (one per entry — `entry_id` is a **UNIQUE** cascade FK, making the PUT endpoint a clean upsert; `placement` free text, `legs_earned` int 0–3, `bob`/`bos`/`bis`/`ris` boolean-mode integers, `notes`). The Shows feature's data model is now complete (Phases 1–4). |
|
| `db/schema.ts` | Drizzle schema definitions: `rabbits` (24 base fields incl. `acquired_date`/`death_date` — the two timeline anchors that aren't derivable, see the rabbit-timeline note below; 7 genetics loci columns, 14 chain-authority `_name_text` ancestor columns; `sex`/`status` enforced via SQLite `CHECK` constraints, not just TS union types; `sire_id`/`dam_id` self-referencing FKs), `litters` (`sire_id`/`dam_id` FKs to `rabbits.id`, `breed_date`, `kindle_date`, `kit_count`, `notes`, `created_at` — a litter row spans the breeding→kindle lifecycle: `breed_date` is set when a breeding is recorded, `kindle_date` is nullable until the doe kindles; at least one of the two is required, enforced in the route), `rabbit_events` (`rabbit_id` FK with `ON DELETE cascade`, `type` (`show`/`sale`/`other`, CHECK-constrained), `event_date`/`title` notNull, `notes`, plus `show_class`/`placement` which are only meaningful for `type='show'` — the manually-logged events behind the rabbit timeline; see the rabbit-timeline note below), and `settings` (`key` text primary key, `value` text — simple key-value store, not a typed config object). The `rabbits` table also carries a nullable `breed` column (added for the Shows feature's entries/report, but surfaced everywhere a rabbit is edited/displayed). `arba_shows_cache` (`id`, `fetched_at`, `data` — a JSON blob of the normalized ARBA show list; only ever one row, the latest scrape; see `lib/arba.ts` + `routes/shows.ts`). `show_events` (`name` notNull, `notes`, `created_at`/`updated_at` — a user-created grouping of ARBA shows; **no `user_id`**, single-user app) and `show_event_shows` (`show_event_id` FK with `ON DELETE cascade`, plus a **snapshot** of the ARBA row at add-time: `sanction_number`/`club_name` notNull, `show_date`/`show_type`/`specialty_breed`/`city`/`state`/`address`/`secretary`/`secretary_email`/`catalog_url`/`maps_url`/`display_order` — copied rather than referencing the cache because ARBA listings are transient). `show_event_entries` (a herd rabbit entered into a specific show: `show_event_show_id` FK `ON DELETE cascade`, `rabbit_id` FK to `rabbits` **without** cascade, `entry_class`/`entry_number`/`notes` free text, `created_at`). `show_event_judges` (`show_event_show_id` cascade FK, `judge_name` notNull, `judge_arba_number`/`notes`). `show_event_placements` (one per entry — `entry_id` is a **UNIQUE** cascade FK, making the PUT endpoint a clean upsert; `placement` free text, `legs_earned` int 0–3, `bob`/`bos`/`bis`/`ris` boolean-mode integers, `notes`). The Shows feature's data model is now complete (Phases 1–4). **User accounts** (`FEATURE_User_Accounts.md`): `users` (`username` notNull UNIQUE — case-sensitive index, case-insensitive enforcement lives in `lower()` queries; `password_hash` notNull `"scrypt:<salt>:<hash>"`; nullable per-user `theme`/`weight_unit` prefs — NULL inherits the global settings value; `created_at`) and `auth_sessions` (`token` text PK, `user_id` FK with a **declarative-only** cascade — the app runs with SQLite `foreign_keys` OFF, so `routes/users.ts` deletes sessions explicitly; `expires_at` ISO, purged lazily on session checks). |
|
||||||
| `db/migrations/` | Generated by `drizzle-kit generate`, applied either manually via `drizzle-kit migrate` (CLI) or automatically on server startup via `db/client.ts`'s in-code `migrate()` call. Both share the same `__drizzle_migrations` tracking table, so there's no double-apply risk mixing the two paths. Contains the SQL migration files (`0000_modern_power_pack.sql` — rabbits, `0001_military_lord_tyger.sql` — litters, `0002_luxuriant_the_stranger.sql` — settings) — these are committed and intentionally **not** excluded by `.dockerignore` (only `node_modules` and `data` are). |
|
| `db/migrations/` | Generated by `drizzle-kit generate`, applied either manually via `drizzle-kit migrate` (CLI) or automatically on server startup via `db/client.ts`'s in-code `migrate()` call. Both share the same `__drizzle_migrations` tracking table, so there's no double-apply risk mixing the two paths. Contains the SQL migration files (`0000_modern_power_pack.sql` — rabbits, `0001_military_lord_tyger.sql` — litters, `0002_luxuriant_the_stranger.sql` — settings) — these are committed and intentionally **not** excluded by `.dockerignore` (only `node_modules` and `data` are). |
|
||||||
| `drizzle.config.ts` | Points `drizzle-kit` at `./db/schema.ts` for migration generation and `./data/mykindlebox.db` as the target DB. |
|
| `drizzle.config.ts` | Points `drizzle-kit` at `./db/schema.ts` for migration generation and `./data/mykindlebox.db` as the target DB. |
|
||||||
| `tsconfig.json` | TypeScript config: CommonJS module output (matches `package.json`'s `"type": "commonjs"`), strict mode on, compiles to `dist/`. |
|
| `tsconfig.json` | TypeScript config: CommonJS module output (matches `package.json`'s `"type": "commonjs"`), strict mode on, compiles to `dist/`. |
|
||||||
|
|
@ -109,4 +109,5 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
|
||||||
- **Rabbit timeline = derived events + column-backed dates + a generic events table** (`RabbitTimeline.jsx`, post-Phase-11 feature): the per-rabbit timeline is built in `buildEvents()` by **merging** three sources, and which source a given event kind comes from is a deliberate design line — don't collapse them. (1) **Derived** (no storage): **birth** (`rabbit.dob`) and **breedings/kindles** (the `litters` where this rabbit is sire/dam — one "Bred with {partner}" event per `breed_date`, one "… kindled" event per `kindle_date`). Because breedings are auto-derived, the "Add Event" dropdown deliberately **omits Breeding** — logging one means creating a litter via the existing Add Breeding/Litter flow, not a manual timeline note (this was an explicit user decision). (2) **Column-backed**: **acquired** and **death** are nullable date columns on `rabbits` (`acquired_date` only renders when set *and* different from `dob`, since a same-day acquired date is just birth), edited through the normal rabbit `PATCH` (they're in `EditRabbit.jsx`'s `DETAIL_KEYS` and rendered by `RabbitDetailsForm.jsx`). (3) **Logged events**: the `rabbit_events` table (`routes/events.ts`) holds `show`/`sale`/`other` — the only kinds with their own CRUD UI, inline in the timeline card via the "Add Event" dropdown. **NOTE the history here**: this started as a `shows`-only table, then the user asked for a generic "Add Event" dropdown, so it was generalized to `rabbit_events` (migrations `0005_drop_shows` + `0006_add_rabbit_events` — drizzle-kit refused to auto-detect the rename non-interactively, so the table was dropped and recreated in two clean generates rather than one). A **sale** event also flips the rabbit's `status` to `sold` server-side (so the client invalidates `['rabbit']`/`['stats']` after one). Sorting is lexicographic on the ISO date string with `EVENT_TYPES[type].order` as the same-day tiebreaker (birth < acquired < breeding < kindle < show < sale < other < death).
|
- **Rabbit timeline = derived events + column-backed dates + a generic events table** (`RabbitTimeline.jsx`, post-Phase-11 feature): the per-rabbit timeline is built in `buildEvents()` by **merging** three sources, and which source a given event kind comes from is a deliberate design line — don't collapse them. (1) **Derived** (no storage): **birth** (`rabbit.dob`) and **breedings/kindles** (the `litters` where this rabbit is sire/dam — one "Bred with {partner}" event per `breed_date`, one "… kindled" event per `kindle_date`). Because breedings are auto-derived, the "Add Event" dropdown deliberately **omits Breeding** — logging one means creating a litter via the existing Add Breeding/Litter flow, not a manual timeline note (this was an explicit user decision). (2) **Column-backed**: **acquired** and **death** are nullable date columns on `rabbits` (`acquired_date` only renders when set *and* different from `dob`, since a same-day acquired date is just birth), edited through the normal rabbit `PATCH` (they're in `EditRabbit.jsx`'s `DETAIL_KEYS` and rendered by `RabbitDetailsForm.jsx`). (3) **Logged events**: the `rabbit_events` table (`routes/events.ts`) holds `show`/`sale`/`other` — the only kinds with their own CRUD UI, inline in the timeline card via the "Add Event" dropdown. **NOTE the history here**: this started as a `shows`-only table, then the user asked for a generic "Add Event" dropdown, so it was generalized to `rabbit_events` (migrations `0005_drop_shows` + `0006_add_rabbit_events` — drizzle-kit refused to auto-detect the rename non-interactively, so the table was dropped and recreated in two clean generates rather than one). A **sale** event also flips the rabbit's `status` to `sold` server-side (so the client invalidates `['rabbit']`/`['stats']` after one). Sorting is lexicographic on the ISO date string with `EVENT_TYPES[type].order` as the same-day tiebreaker (birth < acquired < breeding < kindle < show < sale < other < death).
|
||||||
- **ARBA scraping (Shows feature)** — the show listing comes from scraping `https://arba.net/show-search/`, and two facts about that page are load-bearing; don't "simplify" them away (`lib/arba.ts`): (1) ARBA is behind **Cloudflare** — a bare request returns a "you have been blocked" challenge page, so the scraper sends browser `User-Agent` + `Accept` headers (and deliberately omits `Accept-Encoding` so undici/global-fetch negotiates + decompresses). (2) The data is a WordPress **Ninja Tables** table; its ~2,000 rows are server-rendered into the page HTML as **positional `<td>` cells** (cells have no column classes — read by index 0–10: sanction, date, state, district, city, showInformation, address, maps-`<a>`, secretary, email, catalog-`<a>`). It was tempting to hit the Ninja Tables `admin-ajax.php` data endpoint instead, but for this CSV-backed table that endpoint returns an empty array — the rows only exist in the rendered HTML. Normalization drops cavy shows and non-US rows and parses `showInformation` into club/type/breed by anchoring on the show-type phrase (club names contain commas). Results are cached in `arba_shows_cache` (one row) and only re-scraped when >24h stale or force-refreshed; this matches ARBA's Mon–Fri update cadence and keeps us light on their Cloudflare. **All four phases are built** (the Shows feature is feature-complete per `memory-bank/mykindlebox-shows-design-doc.md`): Phase 1 = browse + cache (`routes/shows.ts`, `Shows.jsx`); Phase 2 = show events + the "Add to Event" flow (`routes/showEvents.ts`, `ShowEvents.jsx`/`ShowEventDetail.jsx`/`AddToEventModal.jsx`); Phase 3 = entries + entries-report (`show_event_entries`, the entry/report endpoints, the Entries tab + `RabbitPicker.jsx`/`ReportModal.jsx`); Phase 4 = judges + placements + Results tab (`show_event_judges`/`show_event_placements`, the judge/placement endpoints, the `JudgesSection`/`PlacementEditor`/`ResultsTab` UI). Not built (explicitly out of scope or deferred): the design doc's optional **Report Settings** (custom header/footer, toggles), **bulk multi-select** "Add selected to Event" from the browser (single per-row add is implemented instead), and judge **editing** UI (the PATCH endpoint exists but the card only adds/removes). Note throughout that the doc's `user_id`/`users` table and `tattoo`/`variety` fields don't exist here — single-user, with `ear_number`/`color_name`/the `breed` column instead.
|
- **ARBA scraping (Shows feature)** — the show listing comes from scraping `https://arba.net/show-search/`, and two facts about that page are load-bearing; don't "simplify" them away (`lib/arba.ts`): (1) ARBA is behind **Cloudflare** — a bare request returns a "you have been blocked" challenge page, so the scraper sends browser `User-Agent` + `Accept` headers (and deliberately omits `Accept-Encoding` so undici/global-fetch negotiates + decompresses). (2) The data is a WordPress **Ninja Tables** table; its ~2,000 rows are server-rendered into the page HTML as **positional `<td>` cells** (cells have no column classes — read by index 0–10: sanction, date, state, district, city, showInformation, address, maps-`<a>`, secretary, email, catalog-`<a>`). It was tempting to hit the Ninja Tables `admin-ajax.php` data endpoint instead, but for this CSV-backed table that endpoint returns an empty array — the rows only exist in the rendered HTML. Normalization drops cavy shows and non-US rows and parses `showInformation` into club/type/breed by anchoring on the show-type phrase (club names contain commas). Results are cached in `arba_shows_cache` (one row) and only re-scraped when >24h stale or force-refreshed; this matches ARBA's Mon–Fri update cadence and keeps us light on their Cloudflare. **All four phases are built** (the Shows feature is feature-complete per `memory-bank/mykindlebox-shows-design-doc.md`): Phase 1 = browse + cache (`routes/shows.ts`, `Shows.jsx`); Phase 2 = show events + the "Add to Event" flow (`routes/showEvents.ts`, `ShowEvents.jsx`/`ShowEventDetail.jsx`/`AddToEventModal.jsx`); Phase 3 = entries + entries-report (`show_event_entries`, the entry/report endpoints, the Entries tab + `RabbitPicker.jsx`/`ReportModal.jsx`); Phase 4 = judges + placements + Results tab (`show_event_judges`/`show_event_placements`, the judge/placement endpoints, the `JudgesSection`/`PlacementEditor`/`ResultsTab` UI). Not built (explicitly out of scope or deferred): the design doc's optional **Report Settings** (custom header/footer, toggles), **bulk multi-select** "Add selected to Event" from the browser (single per-row add is implemented instead), and judge **editing** UI (the PATCH endpoint exists but the card only adds/removes). Note throughout that the doc's `user_id`/`users` table and `tattoo`/`variety` fields don't exist here — single-user, with `ear_number`/`color_name`/the `breed` column instead.
|
||||||
- **OCR/Tesseract assets are vendored, not CDN-fetched, and the `eng.traineddata` is stored uncompressed** (OCR Pedigree Scanning feature, `public/tesseract/`): tesseract.js by default downloads its worker, WASM core, and language data from jsDelivr on first use — unacceptable for a self-hosted offline-first app, so all of it lives under `public/tesseract/` and the worker must be configured with explicit local paths. Two load-bearing facts when wiring up `ocrRun.js` (OCR Phase 3): (1) **`gzip: false` is required** alongside `langPath: '/tesseract/'`, because the vendored `eng.traineddata` is decompressed — the default `gzip: true` makes the worker request `eng.traineddata.gz`, which 404s. (2) **`corePath` should point at the directory `'/tesseract/'`, not a specific file**, so tesseract.js's `wasm-feature-detect` step picks the matching core (`relaxedsimd-lstm` → `simd-lstm` → `lstm`) for the running browser; all three `-lstm` `.wasm.js` cores are vendored for exactly this reason (the app uses the default `LSTM_ONLY` OEM, so the non-`-lstm`/`.asm` cores are never requested and aren't vendored). `workerPath` is `'/tesseract/worker.min.js'`. Don't re-point any of these at a CDN or "slim" the core set down to one file without re-checking the feature-detect branch in `tesseract.js/src/worker-script/browser/getCore.js`. The service worker precaches + cache-firsts these (see `public/sw.js` above).
|
- **OCR/Tesseract assets are vendored, not CDN-fetched, and the `eng.traineddata` is stored uncompressed** (OCR Pedigree Scanning feature, `public/tesseract/`): tesseract.js by default downloads its worker, WASM core, and language data from jsDelivr on first use — unacceptable for a self-hosted offline-first app, so all of it lives under `public/tesseract/` and the worker must be configured with explicit local paths. Two load-bearing facts when wiring up `ocrRun.js` (OCR Phase 3): (1) **`gzip: false` is required** alongside `langPath: '/tesseract/'`, because the vendored `eng.traineddata` is decompressed — the default `gzip: true` makes the worker request `eng.traineddata.gz`, which 404s. (2) **`corePath` should point at the directory `'/tesseract/'`, not a specific file**, so tesseract.js's `wasm-feature-detect` step picks the matching core (`relaxedsimd-lstm` → `simd-lstm` → `lstm`) for the running browser; all three `-lstm` `.wasm.js` cores are vendored for exactly this reason (the app uses the default `LSTM_ONLY` OEM, so the non-`-lstm`/`.asm` cores are never requested and aren't vendored). `workerPath` is `'/tesseract/worker.min.js'`. Don't re-point any of these at a CDN or "slim" the core set down to one file without re-checking the feature-detect branch in `tesseract.js/src/worker-script/browser/getCore.js`. The service worker precaches + cache-firsts these (see `public/sw.js` above).
|
||||||
|
- **Auth is account-based and always on** (`FEATURE_User_Accounts.md`, replacing the original optional shared-password mode): accounts live in `users`, sessions in `auth_sessions` (so restarts don't sign anyone out), passwords are scrypt-hashed via `node:crypto` (**deliberately not bcrypt/argon2** — no native dependency, so the Alpine Docker build is unchanged). All users share the herd; everyone equal (no roles). The first account is created through `POST /api/auth/setup`, which only works while the `users` table is empty — the client's `AuthProvider` shows a `SetupScreen` when `GET /api/auth/status` reports `configured: false`. Any signed-in user manages users at `/api/users` (Settings → Users card); you can't delete yourself, which transitively protects the last account. **`/api/settings` kept its exact single-object shape** but storage split: `theme`/`weight_unit` read/write the caller's `users` row (NULL falls back to the global settings value), the rest stay in the `settings` table — client consumers (ThemeSync, `useWeightUnit`, the Settings page) required no changes. The old `AUTH_PASSWORD`/`MYKINDLEBOX_PASSWORD` env var is retired and ignored (a startup warning fires if it's still set); an upgrading deployment sees the setup screen once and keeps all data. Failed logins get a flat 350 ms delay as brute-force damping.
|
||||||
- **Offline handling is one app-wide banner, not per-query state**: `Layout.jsx`'s `OfflineBanner` listens to the `online`/`offline` `window` events directly — it isn't wired to React Query's error state. This was a deliberate scope choice: every page already has its own `isError` fallback (so nothing crashes when a fetch fails offline), and threading a distinct "offline" message through every individual query would duplicate that existing handling for a cosmetic improvement only. If a future feature needs to distinguish "offline" from "other network error" *within* a specific page's content (not just a banner), that's new logic, not an extension of this banner.
|
- **Offline handling is one app-wide banner, not per-query state**: `Layout.jsx`'s `OfflineBanner` listens to the `online`/`offline` `window` events directly — it isn't wired to React Query's error state. This was a deliberate scope choice: every page already has its own `isError` fallback (so nothing crashes when a fetch fails offline), and threading a distinct "offline" message through every individual query would duplicate that existing handling for a cosmetic improvement only. If a future feature needs to distinguish "offline" from "other network error" *within* a specific page's content (not just a banner), that's new logic, not an extension of this banner.
|
||||||
|
|
|
||||||
|
|
@ -300,3 +300,38 @@ All four modules are pure client-side libs under `client/src/lib/` with no serve
|
||||||
- **Quadrant split deviation (documented)**: the plan says split the grandparent column "at the horizontal and vertical midpoints of the column." Image *height* isn't carried through the pipeline, so `splitGrandparentQuadrants` instead takes the midpoints of the grandparent **words' own bounding extent** (min/max of their `x0/x1`, `y0/y1`) and assigns each word by its center — top-left→`sire_sire`, bottom-left→`sire_dam`, top-right→`dam_sire`, bottom-right→`dam_dam`, per the plan's mapping. Using the text extent (vs. band geometry) also better reflects where the four cells actually sit.
|
- **Quadrant split deviation (documented)**: the plan says split the grandparent column "at the horizontal and vertical midpoints of the column." Image *height* isn't carried through the pipeline, so `splitGrandparentQuadrants` instead takes the midpoints of the grandparent **words' own bounding extent** (min/max of their `x0/x1`, `y0/y1`) and assigns each word by its center — top-left→`sire_sire`, bottom-left→`sire_dam`, top-right→`dam_sire`, bottom-right→`dam_dam`, per the plan's mapping. Using the text extent (vs. band geometry) also better reflects where the four cells actually sit.
|
||||||
|
|
||||||
- **Validation so far**: `npm test` — 34 pass / 0 fail (15 new across the two test files). `npx eslint` clean on all six new files. `npm run build` succeeds (the new `tesseract.js` import resolves; it isn't pulled into the app bundle yet since nothing imports the pipeline until plan Phase 5). **Per the user's request, stopping here for them to validate the Phase 3 tests (especially the browser-only `runOCR`/`extractPedigree` paths against a real pedigree image) before OCR Phase 4 — the UI components — begins.**
|
- **Validation so far**: `npm test` — 34 pass / 0 fail (15 new across the two test files). `npx eslint` clean on all six new files. `npm run build` succeeds (the new `tesseract.js` import resolves; it isn't pulled into the app bundle yet since nothing imports the pipeline until plan Phase 5). **Per the user's request, stopping here for them to validate the Phase 3 tests (especially the browser-only `runOCR`/`extractPedigree` paths against a real pedigree image) before OCR Phase 4 — the UI components — begins.**
|
||||||
|
|
||||||
|
## User Accounts & Onboarding (2026-07-02)
|
||||||
|
|
||||||
|
Replaced the optional shared-password auth with real user accounts — see
|
||||||
|
`memory-bank/FEATURE_User_Accounts.md` for the user-confirmed scope (shared
|
||||||
|
herd, in-app account creation, everyone equal, per-user theme/weight-unit).
|
||||||
|
|
||||||
|
- **Schema** (`0011_add_users.sql`): `users` (username UNIQUE, scrypt
|
||||||
|
`password_hash`, nullable per-user `theme`/`weight_unit`) + `auth_sessions`
|
||||||
|
(token PK, `user_id`, `expires_at` — persisted so restarts don't sign anyone
|
||||||
|
out; cascade is declarative-only since `foreign_keys` is OFF, so user deletion
|
||||||
|
removes sessions explicitly).
|
||||||
|
- **Server**: `auth.ts` rewritten — `hashPassword`/`verifyPassword`
|
||||||
|
(`node:crypto` scrypt, `timingSafeEqual`; deliberately no bcrypt/argon2 native
|
||||||
|
dep), DB sessions with lazy expiry purge, `requireAuth` attaches
|
||||||
|
`req.user`. New `POST /api/auth/setup` (first account only — 409 after),
|
||||||
|
username+password login with a flat 350 ms failed-login delay, and
|
||||||
|
`routes/users.ts` (list/create/reset-password/delete; can't delete self;
|
||||||
|
password reset invalidates the target's other sessions). `/api/settings`
|
||||||
|
kept its exact shape but `theme`/`weight_unit` now read/write the caller's
|
||||||
|
user row (NULL inherits the global value) — zero client-consumer changes.
|
||||||
|
- **Client**: `AuthProvider` gained a third gate (`configured: false` →
|
||||||
|
`SetupScreen`), the login screen takes username+password, context exposes
|
||||||
|
`user`, and Settings gained a `UsersCard` (add/reset/remove) plus
|
||||||
|
"Signed in as …" in the Account card.
|
||||||
|
- **Retired**: `AUTH_PASSWORD`/`MYKINDLEBOX_PASSWORD` (removed from compose +
|
||||||
|
README; the server logs a warning if it's still set). Upgrading deployments
|
||||||
|
see the one-time setup screen; herd data untouched.
|
||||||
|
- **Validation**: server tsc clean; client lint/41-tests/build clean. Full API
|
||||||
|
lifecycle driven over HTTP against a scratch DB: fresh-install status →
|
||||||
|
401 on protected routes → weak-password 400 → setup → second setup 409 →
|
||||||
|
wrong-password 401 (~400 ms) → case-insensitive login → duplicate-username
|
||||||
|
409 → per-user theme divergence with shared `rabbitry_name` → self-delete
|
||||||
|
400 → password reset killing the target's session → delete → logout →
|
||||||
|
**session surviving a server restart**.
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,5 @@
|
||||||
| Styling | Tailwind CSS | Custom theme tokens |
|
| Styling | Tailwind CSS | Custom theme tokens |
|
||||||
| Backend | Node.js + Express | REST API |
|
| Backend | Node.js + Express | REST API |
|
||||||
| Database | SQLite (via Drizzle ORM) | Single-file DB, easy backup |
|
| Database | SQLite (via Drizzle ORM) | Single-file DB, easy backup |
|
||||||
| Auth | Single-user session token | Self-hosted, no OAuth complexity |
|
| Auth | User accounts, shared herd (scrypt + SQLite sessions) | Self-hosted, no OAuth complexity |
|
||||||
| Hosting | Docker Compose | Nginx + Node + SQLite volume |
|
| Hosting | Docker Compose | Nginx + Node + SQLite volume |
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
# MyKindlebox
|
# MyKindlebox
|
||||||
|
|
||||||
A self-hosted rabbitry (rabbit breeding herd) management web app — track your
|
A self-hosted rabbitry (rabbit breeding herd) management web app — track your
|
||||||
rabbits, their pedigrees, litters, and genetics. Single-user, self-hosted, and
|
rabbits, their pedigrees, litters, and genetics. Self-hosted, with simple user
|
||||||
designed to replace a legacy PHP herd-management site.
|
accounts sharing one herd, and designed to replace a legacy PHP
|
||||||
|
herd-management site.
|
||||||
|
|
||||||
The core differentiator: **adding a rabbit starts from a pedigree tree, not a
|
The core differentiator: **adding a rabbit starts from a pedigree tree, not a
|
||||||
blank form.** The 3-generation ancestor tree (sire/dam and their sires/dams) is
|
blank form.** The 3-generation ancestor tree (sire/dam and their sires/dams) is
|
||||||
|
|
@ -35,7 +36,7 @@ herd and graceful fallback to freeform text for bought-in or unknown ancestors.
|
||||||
| Styling | Tailwind CSS |
|
| Styling | Tailwind CSS |
|
||||||
| Backend | Node.js + Express 5 (REST API) |
|
| Backend | Node.js + Express 5 (REST API) |
|
||||||
| Database | SQLite via Drizzle ORM (single-file DB) |
|
| Database | SQLite via Drizzle ORM (single-file DB) |
|
||||||
| Auth | Single-user session token (optional password) |
|
| Auth | User accounts, shared herd (scrypt + DB sessions) |
|
||||||
| Hosting | Docker Compose (Nginx + Node + SQLite volume) |
|
| Hosting | Docker Compose (Nginx + Node + SQLite volume) |
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
@ -101,15 +102,15 @@ listens on 80.)
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
Auth is **optional** and off by default. To require a login, set
|
The app uses **user accounts** — everyone shares the same herd; accounts are
|
||||||
`MYKINDLEBOX_PASSWORD` (e.g. in a `.env` file next to `docker-compose.yml`):
|
separate logins with their own theme and weight-display preference. On first
|
||||||
|
launch the app shows a one-time **create your account** screen; add more people
|
||||||
|
later under **Settings → Users**. Passwords are hashed (scrypt) and sessions
|
||||||
|
last 30 days, surviving server restarts.
|
||||||
|
|
||||||
```env
|
> **Upgrading from the password-only version?** The old `MYKINDLEBOX_PASSWORD`
|
||||||
MYKINDLEBOX_PASSWORD=your-password-here
|
> env var is no longer used — remove it from your `.env`. On your next visit
|
||||||
```
|
> you'll see the one-time account-creation screen; your herd data is untouched.
|
||||||
|
|
||||||
If left unset, the app is open to anyone who can reach it — only acceptable on a
|
|
||||||
fully trusted network.
|
|
||||||
|
|
||||||
## Data & backups
|
## Data & backups
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -143,8 +143,19 @@ export const importData = (file) => {
|
||||||
return request('/import', { method: 'POST', body: formData })
|
return request('/import', { method: 'POST', body: formData })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth
|
// Auth (user accounts — see memory-bank/FEATURE_User_Accounts.md)
|
||||||
export const getAuthStatus = () => request('/auth/status')
|
export const getAuthStatus = () => request('/auth/status')
|
||||||
export const login = (password) =>
|
export const login = (username, password) =>
|
||||||
request('/auth/login', { method: 'POST', body: JSON.stringify({ password }) })
|
request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) })
|
||||||
export const logout = () => request('/auth/logout', { method: 'POST' })
|
export const logout = () => request('/auth/logout', { method: 'POST' })
|
||||||
|
// First-run onboarding: creates the first account (409 once any user exists).
|
||||||
|
export const setupAccount = (username, password) =>
|
||||||
|
request('/auth/setup', { method: 'POST', body: JSON.stringify({ username, password }) })
|
||||||
|
|
||||||
|
// Users (shared herd — users are logins, not data owners)
|
||||||
|
export const getUsers = () => request('/users')
|
||||||
|
export const createUser = (username, password) =>
|
||||||
|
request('/users', { method: 'POST', body: JSON.stringify({ username, password }) })
|
||||||
|
export const resetUserPassword = (id, password) =>
|
||||||
|
request(`/users/${id}`, { method: 'PATCH', body: JSON.stringify({ password }) })
|
||||||
|
export const deleteUser = (id) => request(`/users/${id}`, { method: 'DELETE' })
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,61 @@
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
getAuthStatus,
|
getAuthStatus,
|
||||||
login as apiLogin,
|
login as apiLogin,
|
||||||
logout as apiLogout,
|
logout as apiLogout,
|
||||||
|
setupAccount,
|
||||||
setUnauthorizedHandler,
|
setUnauthorizedHandler,
|
||||||
} from './api'
|
} from './api'
|
||||||
import { AuthContext } from './authContext'
|
import { AuthContext } from './authContext'
|
||||||
|
|
||||||
|
const MIN_PASSWORD_LENGTH = 8 // mirrors the server's MIN_PASSWORD_LENGTH
|
||||||
|
|
||||||
|
function AuthShell({ title, subtitle, onSubmit, error, children }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-surface px-4">
|
||||||
|
<form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="w-full max-w-sm space-y-4 rounded-md border border-border bg-surface-raised p-6"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-2xl text-text-primary">{title}</h1>
|
||||||
|
{subtitle && <p className="mt-1 font-body text-sm text-text-secondary">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
{error && <p className="font-body text-sm text-danger">{error}</p>}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, hint, ...inputProps }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block font-body text-sm font-medium text-text-primary">{label}</label>
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
|
{...inputProps}
|
||||||
|
/>
|
||||||
|
{hint && <p className="mt-1 font-body text-xs text-text-secondary">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubmitButton({ disabled, children }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-full rounded-md bg-accent px-4 py-2 font-body text-sm text-surface hover:bg-accent-hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function LoginScreen({ onSuccess }) {
|
function LoginScreen({ onSuccess }) {
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
@ -18,8 +65,8 @@ function LoginScreen({ onSuccess }) {
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await apiLogin(password)
|
const result = await apiLogin(username, password)
|
||||||
onSuccess()
|
onSuccess(result.user)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Login failed')
|
setError(err.message || 'Login failed')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -28,76 +75,164 @@ function LoginScreen({ onSuccess }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-surface px-4">
|
<AuthShell title="MyKindlebox" onSubmit={handleSubmit} error={error}>
|
||||||
<form
|
<Field
|
||||||
onSubmit={handleSubmit}
|
label="Username"
|
||||||
className="w-full max-w-sm space-y-4 rounded-md border border-border bg-surface-raised p-6"
|
type="text"
|
||||||
>
|
|
||||||
<h1 className="font-display text-2xl text-text-primary">MyKindlebox</h1>
|
|
||||||
<div>
|
|
||||||
<label className="block font-body text-sm font-medium text-text-primary">Password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
autoFocus
|
autoFocus
|
||||||
|
autoComplete="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<SubmitButton disabled={submitting || !username.trim() || !password}>
|
||||||
{error && <p className="font-body text-sm text-danger">{error}</p>}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={submitting || !password}
|
|
||||||
className="w-full rounded-md bg-accent px-4 py-2 font-body text-sm text-surface hover:bg-accent-hover disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{submitting ? 'Signing in…' : 'Sign in'}
|
{submitting ? 'Signing in…' : 'Sign in'}
|
||||||
</button>
|
</SubmitButton>
|
||||||
</form>
|
</AuthShell>
|
||||||
</div>
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First-run onboarding: shown only while the server reports `configured: false`
|
||||||
|
// (no users exist yet). Creates the first account and signs it in. More
|
||||||
|
// accounts are added later from Settings → Users.
|
||||||
|
function SetupScreen({ onSuccess }) {
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirm, setConfirm] = useState('')
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (password !== confirm) {
|
||||||
|
setError('Passwords do not match')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const result = await setupAccount(username.trim(), password)
|
||||||
|
onSuccess(result.user)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Setup failed')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid =
|
||||||
|
username.trim().length > 0 && password.length >= MIN_PASSWORD_LENGTH && confirm.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthShell
|
||||||
|
title="Welcome to MyKindlebox"
|
||||||
|
subtitle="Create your account to get started. You can add accounts for other people later, under Settings."
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
error={error}
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
label="Username"
|
||||||
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
autoComplete="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
hint={`At least ${MIN_PASSWORD_LENGTH} characters.`}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Confirm password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
/>
|
||||||
|
<SubmitButton disabled={submitting || !valid}>
|
||||||
|
{submitting ? 'Creating account…' : 'Create account'}
|
||||||
|
</SubmitButton>
|
||||||
|
</AuthShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gates the whole app behind the server's auth state. The server is the source
|
// Gates the whole app behind the server's auth state. The server is the source
|
||||||
// of truth (fetched via React Query, like every other server read); `session`
|
// of truth (fetched via React Query, like every other server read); `session`
|
||||||
// is a local override for client-driven transitions — login, logout, or a 401
|
// is a local override for client-driven transitions — login, setup, logout, or
|
||||||
// on any protected request — that shouldn't wait on a refetch to take effect.
|
// a 401 on any protected request — that shouldn't wait on a refetch to take
|
||||||
|
// effect. Three gates: no users yet → SetupScreen (first-run onboarding),
|
||||||
|
// unauthenticated → LoginScreen, else the app with `user` in context.
|
||||||
export function AuthProvider({ children }) {
|
export function AuthProvider({ children }) {
|
||||||
const { data, isLoading } = useQuery({
|
const queryClient = useQueryClient()
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
queryKey: ['auth-status'],
|
queryKey: ['auth-status'],
|
||||||
queryFn: getAuthStatus,
|
queryFn: getAuthStatus,
|
||||||
retry: false,
|
retry: false,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
})
|
})
|
||||||
const [session, setSession] = useState(null)
|
const [session, setSession] = useState(null) // { authenticated, user } override
|
||||||
|
|
||||||
// Subscribing to an external signal (the api layer's 401 hook) — the callback
|
// Subscribing to an external signal (the api layer's 401 hook) — the callback
|
||||||
// sets state, the effect body itself doesn't.
|
// sets state, the effect body itself doesn't.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUnauthorizedHandler(() => setSession(false))
|
setUnauthorizedHandler(() => setSession({ authenticated: false, user: null }))
|
||||||
return () => setUnauthorizedHandler(null)
|
return () => setUnauthorizedHandler(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleAuthed = useCallback(
|
||||||
|
(user) => {
|
||||||
|
setSession({ authenticated: true, user })
|
||||||
|
// Refresh the canonical copy — after first-run setup this also flips
|
||||||
|
// `configured` to true so a later logout lands on Login, not Setup.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['auth-status'] })
|
||||||
|
},
|
||||||
|
[queryClient],
|
||||||
|
)
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await apiLogout()
|
await apiLogout()
|
||||||
} finally {
|
} finally {
|
||||||
setSession(false)
|
setSession({ authenticated: false, user: null })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['auth-status'] })
|
||||||
}
|
}
|
||||||
}, [])
|
}, [queryClient])
|
||||||
|
|
||||||
if (isLoading) return null
|
if (isLoading) return null
|
||||||
|
|
||||||
// No data (server unreachable) is treated as "auth not configured" so the app
|
// Server unreachable: render the app anyway so each page can show its own
|
||||||
// still renders and can show its own offline/error states per page.
|
// offline/error state instead of a dead-end auth screen.
|
||||||
const configured = data?.configured ?? false
|
if (isError) {
|
||||||
if (!configured) {
|
return (
|
||||||
return <AuthContext.Provider value={{ mode: 'open', logout }}>{children}</AuthContext.Provider>
|
<AuthContext.Provider value={{ mode: 'open', user: null, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const authenticated = session ?? data?.authenticated ?? false
|
const authenticated = session?.authenticated ?? data?.authenticated ?? false
|
||||||
if (!authenticated) {
|
const user = session?.user ?? data?.user ?? null
|
||||||
return <LoginScreen onSuccess={() => setSession(true)} />
|
|
||||||
|
if (authenticated) {
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ mode: 'authed', user, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <AuthContext.Provider value={{ mode: 'authed', logout }}>{children}</AuthContext.Provider>
|
if (!data?.configured) {
|
||||||
|
return <SetupScreen onSuccess={handleAuthed} />
|
||||||
|
}
|
||||||
|
return <LoginScreen onSuccess={handleAuthed} />
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { createContext, useContext } from 'react'
|
||||||
// Split out from auth.jsx so that file can export only components (the
|
// Split out from auth.jsx so that file can export only components (the
|
||||||
// react-refresh lint rule), and so pages can import useAuth without pulling in
|
// react-refresh lint rule), and so pages can import useAuth without pulling in
|
||||||
// the provider.
|
// the provider.
|
||||||
export const AuthContext = createContext({ mode: 'open', logout: () => {} })
|
export const AuthContext = createContext({ mode: 'open', user: null, logout: () => {} })
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
return useContext(AuthContext)
|
return useContext(AuthContext)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import Card from './Card'
|
||||||
|
import Button from './Button'
|
||||||
|
import { getUsers, createUser, resetUserPassword, deleteUser } from '../api'
|
||||||
|
import { useAuth } from '../authContext'
|
||||||
|
|
||||||
|
const MIN_PASSWORD_LENGTH = 8 // mirrors the server's MIN_PASSWORD_LENGTH
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent'
|
||||||
|
|
||||||
|
// Settings → Users (memory-bank/FEATURE_User_Accounts.md): everyone-equal user
|
||||||
|
// management for the shared herd. Any signed-in user can add users, reset any
|
||||||
|
// password (that's the "someone forgot theirs" path — no current-password
|
||||||
|
// check), and delete accounts other than their own.
|
||||||
|
export default function UsersCard({ onToast }) {
|
||||||
|
const { user: me } = useAuth()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { data: users, isLoading } = useQuery({ queryKey: ['users'], queryFn: getUsers })
|
||||||
|
|
||||||
|
const [newUsername, setNewUsername] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [addError, setAddError] = useState(null)
|
||||||
|
const [resetTarget, setResetTarget] = useState(null) // user id with the reset form open
|
||||||
|
const [resetPassword, setResetPassword] = useState('')
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState(null) // user object pending confirm
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
async function handleAdd(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setBusy(true)
|
||||||
|
setAddError(null)
|
||||||
|
try {
|
||||||
|
await createUser(newUsername.trim(), newPassword)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||||
|
onToast(`Added ${newUsername.trim()}`)
|
||||||
|
setNewUsername('')
|
||||||
|
setNewPassword('')
|
||||||
|
} catch (err) {
|
||||||
|
setAddError(err.message || 'Could not add user')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReset(user) {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await resetUserPassword(user.id, resetPassword)
|
||||||
|
onToast(`Password updated for ${user.username}`)
|
||||||
|
setResetTarget(null)
|
||||||
|
setResetPassword('')
|
||||||
|
} catch (err) {
|
||||||
|
onToast(err.message || 'Could not update password')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await deleteUser(deleteTarget.id)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||||
|
onToast(`Removed ${deleteTarget.username}`)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
} catch (err) {
|
||||||
|
onToast(err.message || 'Could not remove user')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="space-y-4 p-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-lg text-text-primary">Users</h2>
|
||||||
|
<p className="mt-1 font-body text-sm text-text-secondary">
|
||||||
|
Everyone here shares the same herd and can manage users. Theme and weight display are
|
||||||
|
per-user; the rest of Settings is shared.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="font-body text-sm text-text-secondary">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{(users ?? []).map((user) => (
|
||||||
|
<li key={user.id} className="py-3 first:pt-0 last:pb-0">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="min-w-0 truncate font-body text-sm font-medium text-text-primary">
|
||||||
|
{user.username}
|
||||||
|
{me?.id === user.id && (
|
||||||
|
<span className="ml-2 rounded bg-surface-sunken px-1.5 py-0.5 font-body text-xs text-text-secondary">
|
||||||
|
you
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="flex shrink-0 gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setResetTarget(resetTarget === user.id ? null : user.id)
|
||||||
|
setResetPassword('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{resetTarget === user.id ? 'Cancel' : 'Reset password'}
|
||||||
|
</Button>
|
||||||
|
{me?.id !== user.id && (
|
||||||
|
<Button variant="danger" onClick={() => setDeleteTarget(user)}>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{resetTarget === user.id && (
|
||||||
|
<div className="mt-2 flex items-end gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block font-body text-xs text-text-secondary">
|
||||||
|
New password (min {MIN_PASSWORD_LENGTH} characters)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoFocus
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={resetPassword}
|
||||||
|
onChange={(e) => setResetPassword(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleReset(user)}
|
||||||
|
disabled={busy || resetPassword.length < MIN_PASSWORD_LENGTH}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleAdd} className="space-y-3 border-t border-border pt-4">
|
||||||
|
<h3 className="font-body text-sm font-medium text-text-primary">Add a user</h3>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="block font-body text-xs text-text-secondary">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
value={newUsername}
|
||||||
|
onChange={(e) => setNewUsername(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block font-body text-xs text-text-secondary">
|
||||||
|
Password (min {MIN_PASSWORD_LENGTH} characters)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{addError && <p className="font-body text-sm text-danger">{addError}</p>}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || !newUsername.trim() || newPassword.length < MIN_PASSWORD_LENGTH}
|
||||||
|
>
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{deleteTarget && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||||
|
<Card className="w-full max-w-md space-y-4 p-6">
|
||||||
|
<h2 className="font-display text-lg text-text-primary">
|
||||||
|
Remove {deleteTarget.username}?
|
||||||
|
</h2>
|
||||||
|
<p className="font-body text-sm text-text-secondary">
|
||||||
|
They will be signed out everywhere and can no longer log in. The herd data is shared,
|
||||||
|
so nothing else is deleted.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="secondary" onClick={() => setDeleteTarget(null)} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={handleDelete} disabled={busy}>
|
||||||
|
{busy ? 'Removing…' : 'Remove User'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import PageHeader from '../components/PageHeader'
|
||||||
import Card from '../components/Card'
|
import Card from '../components/Card'
|
||||||
import Button from '../components/Button'
|
import Button from '../components/Button'
|
||||||
import BreedSelect from '../components/BreedSelect'
|
import BreedSelect from '../components/BreedSelect'
|
||||||
|
import UsersCard from '../components/UsersCard'
|
||||||
import { useAuth } from '../authContext'
|
import { useAuth } from '../authContext'
|
||||||
import { getSettings, updateSettings, exportDatabaseUrl, importData } from '../api'
|
import { getSettings, updateSettings, exportDatabaseUrl, importData } from '../api'
|
||||||
import { THEMES, applyTheme, DEFAULT_THEME } from '../lib/theme'
|
import { THEMES, applyTheme, DEFAULT_THEME } from '../lib/theme'
|
||||||
|
|
@ -23,7 +24,7 @@ function Toast({ message }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function Settings() {
|
function Settings() {
|
||||||
const { mode, logout } = useAuth()
|
const { mode, user, logout } = useAuth()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: settings, isLoading } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
|
const { data: settings, isLoading } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
|
||||||
const [form, setForm] = useState(null)
|
const [form, setForm] = useState(null)
|
||||||
|
|
@ -257,17 +258,20 @@ function Settings() {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{mode === 'authed' && (
|
{mode === 'authed' && (
|
||||||
|
<>
|
||||||
|
<UsersCard onToast={setToast} />
|
||||||
<Card className="space-y-3 p-6">
|
<Card className="space-y-3 p-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-display text-lg text-text-primary">Account</h2>
|
<h2 className="font-display text-lg text-text-primary">Account</h2>
|
||||||
<p className="mt-1 font-body text-sm text-text-secondary">
|
<p className="mt-1 font-body text-sm text-text-secondary">
|
||||||
End this session on this device.
|
{user ? `Signed in as ${user.username}. ` : ''}End this session on this device.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" onClick={logout}>
|
<Button variant="secondary" onClick={logout}>
|
||||||
Log Out
|
Log Out
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@ services:
|
||||||
server:
|
server:
|
||||||
build: ./server
|
build: ./server
|
||||||
environment:
|
environment:
|
||||||
# Set MYKINDLEBOX_PASSWORD (e.g. in a .env file next to this compose file)
|
# Auth is account-based (users live in the database): the first visit
|
||||||
# to require a login. If left empty, auth is DISABLED and the app is open
|
# shows a one-time account-creation screen, and more users are added in
|
||||||
# to anyone who can reach it — only acceptable on a fully trusted network.
|
# Settings. The old MYKINDLEBOX_PASSWORD/AUTH_PASSWORD env var is no
|
||||||
AUTH_PASSWORD: ${MYKINDLEBOX_PASSWORD:-}
|
# longer used.
|
||||||
DATA_DIR: /app/data
|
DATA_DIR: /app/data
|
||||||
volumes:
|
volumes:
|
||||||
- server-data:/app/data
|
- server-data:/app/data
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,91 @@
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID, randomBytes, scryptSync, timingSafeEqual } from 'crypto';
|
||||||
import { NextFunction, Request, Response } from 'express';
|
import { NextFunction, Request, Response } from 'express';
|
||||||
|
import { eq, lt, sql } from 'drizzle-orm';
|
||||||
|
import { db } from './db/client';
|
||||||
|
import { users, authSessions } from './db/schema';
|
||||||
|
|
||||||
// Single-user session-token auth (no OAuth, per tech-stack.md).
|
// User-account session auth (see memory-bank/FEATURE_User_Accounts.md).
|
||||||
//
|
//
|
||||||
// A single shared password is read from the AUTH_PASSWORD env var. If it's
|
// Replaces the original single shared-password mode (AUTH_PASSWORD env var,
|
||||||
// unset/empty, auth is DISABLED — every request passes through — which keeps
|
// in-memory session Set): accounts live in the `users` table, sessions in
|
||||||
// local dev frictionless. When set, clients must POST it to /api/auth/login to
|
// `auth_sessions` (so a server restart no longer signs everyone out), and the
|
||||||
// receive an httpOnly session cookie, checked on every protected route.
|
// first account is created through POST /api/auth/setup while the users table
|
||||||
//
|
// is empty. Auth is always on once a user exists — there is no open mode.
|
||||||
// Tokens live in memory only: a server restart invalidates all sessions
|
|
||||||
// (you log in again). That's an acceptable trade for a single-user, self-hosted
|
|
||||||
// app and avoids persisting a session table.
|
|
||||||
|
|
||||||
const COOKIE_NAME = 'mkb_session';
|
const COOKIE_NAME = 'mkb_session';
|
||||||
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
|
|
||||||
const password = process.env.AUTH_PASSWORD ?? '';
|
// Flat pause on a failed login — cheap brute-force damping without the
|
||||||
export const authEnabled = password.length > 0;
|
// bookkeeping of per-IP counters (single-family LAN app).
|
||||||
|
const FAILED_LOGIN_DELAY_MS = 350;
|
||||||
|
|
||||||
const sessions = new Set<string>();
|
export interface SessionUser {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function logAuthStatus() {
|
// requireAuth attaches the session's user here; protected routes can cast.
|
||||||
if (authEnabled) {
|
export interface AuthedRequest extends Request {
|
||||||
console.log('Auth: enabled (AUTH_PASSWORD is set).');
|
user?: SessionUser;
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
'Auth: DISABLED — AUTH_PASSWORD is not set, so the API is open to anyone ' +
|
|
||||||
'who can reach it. Set AUTH_PASSWORD to require a login.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Password hashing (node:crypto scrypt — no native dependency) ---
|
||||||
|
|
||||||
|
export function hashPassword(password: string): string {
|
||||||
|
const salt = randomBytes(16);
|
||||||
|
const hash = scryptSync(password, salt, 64);
|
||||||
|
return `scrypt:${salt.toString('hex')}:${hash.toString('hex')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function verifyPassword(password: string, stored: string): boolean {
|
||||||
|
const [scheme, saltHex, hashHex] = stored.split(':');
|
||||||
|
if (scheme !== 'scrypt' || !saltHex || !hashHex) return false;
|
||||||
|
const expected = Buffer.from(hashHex, 'hex');
|
||||||
|
const actual = scryptSync(password, Buffer.from(saltHex, 'hex'), expected.length);
|
||||||
|
return timingSafeEqual(actual, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Users ---
|
||||||
|
|
||||||
|
export function anyUsersExist(): boolean {
|
||||||
|
return db.select({ id: users.id }).from(users).limit(1).get() !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case-insensitive lookup: the schema's UNIQUE index is case-sensitive, so both
|
||||||
|
// login and the duplicate-username guard go through lower() instead.
|
||||||
|
export function findUserByUsername(username: string) {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(sql`lower(${users.username}) = lower(${username})`)
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const failedLoginDelayMs = FAILED_LOGIN_DELAY_MS;
|
||||||
|
|
||||||
|
export const MIN_PASSWORD_LENGTH = 8;
|
||||||
|
|
||||||
|
// Shared by POST /api/auth/setup and POST /api/users — returns an error message
|
||||||
|
// for the 400 response, or null when the pair is acceptable.
|
||||||
|
export function validateNewCredentials(username: unknown, password: unknown): string | null {
|
||||||
|
if (typeof username !== 'string' || username.trim().length === 0) {
|
||||||
|
return 'Username is required';
|
||||||
|
}
|
||||||
|
if (username.trim().length > 64) {
|
||||||
|
return 'Username is too long (max 64 characters)';
|
||||||
|
}
|
||||||
|
if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) {
|
||||||
|
return `Password must be at least ${MIN_PASSWORD_LENGTH} characters`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cookie / session plumbing ---
|
||||||
|
|
||||||
function parseCookies(header: string | undefined): Record<string, string> {
|
function parseCookies(header: string | undefined): Record<string, string> {
|
||||||
const out: Record<string, string> = {};
|
const out: Record<string, string> = {};
|
||||||
if (!header) return out;
|
if (!header) return out;
|
||||||
|
|
@ -43,41 +98,65 @@ function parseCookies(header: string | undefined): Record<string, string> {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readSessionToken(req: Request): string | undefined {
|
export function readSessionToken(req: Request): string | undefined {
|
||||||
return parseCookies(req.headers.cookie)[COOKIE_NAME];
|
return parseCookies(req.headers.cookie)[COOKIE_NAME];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAuthenticated(req: Request): boolean {
|
export function createSession(res: Response, userId: number): void {
|
||||||
if (!authEnabled) return true;
|
|
||||||
const token = readSessionToken(req);
|
|
||||||
return token != null && sessions.has(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function requireAuth(req: Request, res: Response, next: NextFunction) {
|
|
||||||
if (isAuthenticated(req)) {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.status(401).json({ error: 'Unauthorized' });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function login(res: Response, attempt: unknown): boolean {
|
|
||||||
if (!authEnabled) return true;
|
|
||||||
if (typeof attempt !== 'string' || attempt !== password) return false;
|
|
||||||
|
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
sessions.add(token);
|
const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
|
||||||
|
db.insert(authSessions).values({ token, userId, expiresAt }).run();
|
||||||
res.cookie(COOKIE_NAME, token, {
|
res.cookie(COOKIE_NAME, token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
path: '/',
|
path: '/',
|
||||||
maxAge: SESSION_TTL_MS,
|
maxAge: SESSION_TTL_MS,
|
||||||
});
|
});
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logout(req: Request, res: Response) {
|
export function destroySession(req: Request, res: Response): void {
|
||||||
const token = readSessionToken(req);
|
const token = readSessionToken(req);
|
||||||
if (token) sessions.delete(token);
|
if (token) db.delete(authSessions).where(eq(authSessions.token, token)).run();
|
||||||
res.clearCookie(COOKIE_NAME, { path: '/' });
|
res.clearCookie(COOKIE_NAME, { path: '/' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the request's session to its user, lazily purging expired sessions.
|
||||||
|
// ISO strings compare lexicographically, so `lt` against now-ISO is correct.
|
||||||
|
export function getSessionUser(req: Request): SessionUser | null {
|
||||||
|
const token = readSessionToken(req);
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
db.delete(authSessions).where(lt(authSessions.expiresAt, new Date().toISOString())).run();
|
||||||
|
|
||||||
|
const row = db
|
||||||
|
.select({ id: users.id, username: users.username })
|
||||||
|
.from(authSessions)
|
||||||
|
.innerJoin(users, eq(authSessions.userId, users.id))
|
||||||
|
.where(eq(authSessions.token, token))
|
||||||
|
.get();
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||||
|
const user = getSessionUser(req);
|
||||||
|
if (!user) {
|
||||||
|
res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(req as AuthedRequest).user = user;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logAuthStatus() {
|
||||||
|
if (process.env.AUTH_PASSWORD) {
|
||||||
|
console.warn(
|
||||||
|
'Auth: AUTH_PASSWORD is set but no longer used — auth is account-based now. ' +
|
||||||
|
'Remove MYKINDLEBOX_PASSWORD/AUTH_PASSWORD from your environment.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (anyUsersExist()) {
|
||||||
|
console.log('Auth: user accounts active.');
|
||||||
|
} else {
|
||||||
|
console.log('Auth: no users yet — the app will show the first-run setup screen.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
CREATE TABLE `auth_sessions` (
|
||||||
|
`token` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`expires_at` text NOT NULL,
|
||||||
|
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `users` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`username` text NOT NULL,
|
||||||
|
`password_hash` text NOT NULL,
|
||||||
|
`theme` text,
|
||||||
|
`weight_unit` text,
|
||||||
|
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -78,6 +78,13 @@
|
||||||
"when": 1782183462143,
|
"when": 1782183462143,
|
||||||
"tag": "0010_add_judges_placements",
|
"tag": "0010_add_judges_placements",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1783023645526,
|
||||||
|
"tag": "0011_add_users",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -112,6 +112,37 @@ export const settings = sqliteTable('settings', {
|
||||||
value: text('value'),
|
value: text('value'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// User accounts (see memory-bank/FEATURE_User_Accounts.md). All users share the
|
||||||
|
// same herd — nothing else references users; they are logins, not data owners.
|
||||||
|
// `theme`/`weightUnit` are the per-user preferences: NULL means "inherit the
|
||||||
|
// global settings-table value" (routes/settings.ts does the merge), so existing
|
||||||
|
// global values keep applying until a user picks their own.
|
||||||
|
export const users = sqliteTable('users', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
// The UNIQUE index is case-sensitive (drizzle can't emit COLLATE NOCASE);
|
||||||
|
// case-insensitive lookups and the "Rory" vs "rory" duplicate guard are both
|
||||||
|
// enforced with lower() comparisons in auth.ts / routes/users.ts instead.
|
||||||
|
username: text('username').notNull().unique(),
|
||||||
|
passwordHash: text('password_hash').notNull(), // "scrypt:<salt hex>:<hash hex>" — see auth.ts
|
||||||
|
theme: text('theme'),
|
||||||
|
weightUnit: text('weight_unit'),
|
||||||
|
createdAt: text('created_at').notNull().default(sql`(current_timestamp)`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persistent sessions (replaces the old in-memory Set): a server restart or
|
||||||
|
// redeploy no longer signs everyone out. Expired rows are purged lazily during
|
||||||
|
// session checks. NOTE: the FK cascade is declarative only — the app runs with
|
||||||
|
// SQLite foreign_keys OFF (better-sqlite3 default), so user deletion removes
|
||||||
|
// sessions explicitly in routes/users.ts.
|
||||||
|
export const authSessions = sqliteTable('auth_sessions', {
|
||||||
|
token: text('token').primaryKey(),
|
||||||
|
userId: integer('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
expiresAt: text('expires_at').notNull(),
|
||||||
|
createdAt: text('created_at').notNull().default(sql`(current_timestamp)`),
|
||||||
|
});
|
||||||
|
|
||||||
// Cached snapshot of the ARBA show-search listing (see routes/shows.ts and
|
// Cached snapshot of the ARBA show-search listing (see routes/shows.ts and
|
||||||
// lib/arba.ts). ARBA sits behind Cloudflare and only updates Mon–Fri, so we
|
// lib/arba.ts). ARBA sits behind Cloudflare and only updates Mon–Fri, so we
|
||||||
// scrape at most once a day and serve everything else from here. `data` is the
|
// scrape at most once a day and serve everything else from here. `data` is the
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import cors from 'cors';
|
||||||
import { uploadsDir } from './db/client';
|
import { uploadsDir } from './db/client';
|
||||||
import { logAuthStatus, requireAuth } from './auth';
|
import { logAuthStatus, requireAuth } from './auth';
|
||||||
import authRouter from './routes/auth';
|
import authRouter from './routes/auth';
|
||||||
|
import usersRouter from './routes/users';
|
||||||
import rabbitsRouter from './routes/rabbits';
|
import rabbitsRouter from './routes/rabbits';
|
||||||
import littersRouter from './routes/litters';
|
import littersRouter from './routes/litters';
|
||||||
import eventsRouter from './routes/events';
|
import eventsRouter from './routes/events';
|
||||||
|
|
@ -35,8 +36,9 @@ app.get('/health', (req, res) => {
|
||||||
// Auth endpoints must stay open so the login screen can reach them.
|
// Auth endpoints must stay open so the login screen can reach them.
|
||||||
app.use('/api/auth', authRouter);
|
app.use('/api/auth', authRouter);
|
||||||
|
|
||||||
// Everything past here requires a valid session (a no-op when auth is disabled).
|
// Everything past here requires a valid session.
|
||||||
app.use('/uploads', requireAuth, express.static(uploadsDir));
|
app.use('/uploads', requireAuth, express.static(uploadsDir));
|
||||||
|
app.use('/api/users', requireAuth, usersRouter);
|
||||||
app.use('/api/rabbits', requireAuth, rabbitsRouter);
|
app.use('/api/rabbits', requireAuth, rabbitsRouter);
|
||||||
app.use('/api/litters', requireAuth, littersRouter);
|
app.use('/api/litters', requireAuth, littersRouter);
|
||||||
app.use('/api/events', requireAuth, eventsRouter);
|
app.use('/api/events', requireAuth, eventsRouter);
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,75 @@
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { authEnabled, isAuthenticated, login, logout } from '../auth';
|
import { db } from '../db/client';
|
||||||
|
import { users } from '../db/schema';
|
||||||
|
import {
|
||||||
|
anyUsersExist,
|
||||||
|
createSession,
|
||||||
|
destroySession,
|
||||||
|
failedLoginDelayMs,
|
||||||
|
findUserByUsername,
|
||||||
|
getSessionUser,
|
||||||
|
hashPassword,
|
||||||
|
sleep,
|
||||||
|
validateNewCredentials,
|
||||||
|
verifyPassword,
|
||||||
|
} from '../auth';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Lets the client decide whether to show a login screen at all:
|
// Drives the client's auth gate: `configured: false` → show the first-run
|
||||||
// `configured: false` means auth is disabled, so the app loads straight through.
|
// setup screen; unauthenticated → login screen; otherwise `user` identifies
|
||||||
|
// the session for the UI.
|
||||||
router.get('/status', (req, res) => {
|
router.get('/status', (req, res) => {
|
||||||
res.json({ configured: authEnabled, authenticated: isAuthenticated(req) });
|
const user = getSessionUser(req);
|
||||||
|
res.json({
|
||||||
|
configured: anyUsersExist(),
|
||||||
|
authenticated: user !== null,
|
||||||
|
user: user ?? null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/login', (req, res) => {
|
// First-run onboarding: creates the first account and signs it in. Locked to
|
||||||
const ok = login(res, req.body?.password);
|
// the empty-users state so it can never be used to add accounts later (that's
|
||||||
if (!ok) {
|
// POST /api/users, which requires a session).
|
||||||
res.status(401).json({ error: 'Incorrect password' });
|
router.post('/setup', (req, res) => {
|
||||||
|
if (anyUsersExist()) {
|
||||||
|
res.status(409).json({ error: 'Setup has already been completed' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.json({ authenticated: true });
|
|
||||||
|
const { username, password } = req.body ?? {};
|
||||||
|
const invalid = validateNewCredentials(username, password);
|
||||||
|
if (invalid) {
|
||||||
|
res.status(400).json({ error: invalid });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = db
|
||||||
|
.insert(users)
|
||||||
|
.values({ username: (username as string).trim(), passwordHash: hashPassword(password as string) })
|
||||||
|
.returning({ id: users.id, username: users.username })
|
||||||
|
.get();
|
||||||
|
|
||||||
|
createSession(res, created.id);
|
||||||
|
res.status(201).json({ authenticated: true, user: created });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
const { username, password } = req.body ?? {};
|
||||||
|
const user = typeof username === 'string' ? findUserByUsername(username) : undefined;
|
||||||
|
|
||||||
|
if (!user || typeof password !== 'string' || !verifyPassword(password, user.passwordHash)) {
|
||||||
|
await sleep(failedLoginDelayMs); // flat delay = cheap brute-force damping
|
||||||
|
res.status(401).json({ error: 'Incorrect username or password' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createSession(res, user.id);
|
||||||
|
res.json({ authenticated: true, user: { id: user.id, username: user.username } });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/logout', (req, res) => {
|
router.post('/logout', (req, res) => {
|
||||||
logout(req, res);
|
destroySession(req, res);
|
||||||
res.json({ authenticated: false });
|
res.json({ authenticated: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,69 @@
|
||||||
import { Router } from 'express';
|
import { Router, Request } from 'express';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { db } from '../db/client';
|
import { db } from '../db/client';
|
||||||
import { settings } from '../db/schema';
|
import { settings, users } from '../db/schema';
|
||||||
|
import { AuthedRequest } from '../auth';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
const ALLOWED_KEYS = ['rabbitry_name', 'weight_unit', 'kit_prefix', 'theme', 'default_breed'] as const;
|
// The API shape is unchanged from the single-user days — one flat object with
|
||||||
|
// these keys — but storage is now split (FEATURE_User_Accounts.md): `theme` and
|
||||||
|
// `weight_unit` are per-user preferences on the caller's `users` row, the rest
|
||||||
|
// stay global in the settings table. A NULL user pref falls back to the global
|
||||||
|
// value, so a fresh account inherits the rabbitry-wide defaults until the user
|
||||||
|
// picks their own. Client consumers (ThemeSync, useWeightUnit, Settings page)
|
||||||
|
// are agnostic to the split.
|
||||||
|
const GLOBAL_KEYS = ['rabbitry_name', 'kit_prefix', 'default_breed'] as const;
|
||||||
|
const USER_KEYS = ['theme', 'weight_unit'] as const;
|
||||||
|
const ALLOWED_KEYS = [...GLOBAL_KEYS, ...USER_KEYS] as string[];
|
||||||
|
|
||||||
router.get('/', (req, res) => {
|
function mergedSettings(userId: number): Record<string, string | null> {
|
||||||
const rows = db.select().from(settings).all();
|
|
||||||
const result: Record<string, string | null> = {};
|
const result: Record<string, string | null> = {};
|
||||||
for (const row of rows) {
|
for (const row of db.select().from(settings).all()) {
|
||||||
result[row.key] = row.value;
|
result[row.key] = row.value;
|
||||||
}
|
}
|
||||||
res.json(result);
|
const prefs = db
|
||||||
|
.select({ theme: users.theme, weightUnit: users.weightUnit })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.get();
|
||||||
|
if (prefs?.theme != null) result.theme = prefs.theme;
|
||||||
|
if (prefs?.weightUnit != null) result.weight_unit = prefs.weightUnit;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentUserId(req: Request): number {
|
||||||
|
return (req as AuthedRequest).user!.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
res.json(mergedSettings(currentUserId(req)));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.patch('/', (req, res) => {
|
router.patch('/', (req, res) => {
|
||||||
const keys = Object.keys(req.body);
|
const keys = Object.keys(req.body);
|
||||||
const unknownKey = keys.find((key) => !ALLOWED_KEYS.includes(key as (typeof ALLOWED_KEYS)[number]));
|
const unknownKey = keys.find((key) => !ALLOWED_KEYS.includes(key));
|
||||||
|
|
||||||
if (unknownKey) {
|
if (unknownKey) {
|
||||||
res.status(400).json({ error: `Unknown setting: ${unknownKey}` });
|
res.status(400).json({ error: `Unknown setting: ${unknownKey}` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userId = currentUserId(req);
|
||||||
|
|
||||||
|
const userPatch: { theme?: string; weightUnit?: string } = {};
|
||||||
|
if ('theme' in req.body) userPatch.theme = req.body.theme;
|
||||||
|
if ('weight_unit' in req.body) userPatch.weightUnit = req.body.weight_unit;
|
||||||
|
if (Object.keys(userPatch).length > 0) {
|
||||||
|
db.update(users).set(userPatch).where(eq(users.id, userId)).run();
|
||||||
|
}
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
|
if ((USER_KEYS as readonly string[]).includes(key)) continue;
|
||||||
db.update(settings).set({ value: req.body[key] }).where(eq(settings.key, key)).run();
|
db.update(settings).set({ value: req.body[key] }).where(eq(settings.key, key)).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = db.select().from(settings).all();
|
res.json(mergedSettings(userId));
|
||||||
const result: Record<string, string | null> = {};
|
|
||||||
for (const row of rows) {
|
|
||||||
result[row.key] = row.value;
|
|
||||||
}
|
|
||||||
res.json(result);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { Router, Request } from 'express';
|
||||||
|
import { and, asc, eq, ne } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/client';
|
||||||
|
import { users, authSessions } from '../db/schema';
|
||||||
|
import {
|
||||||
|
AuthedRequest,
|
||||||
|
findUserByUsername,
|
||||||
|
hashPassword,
|
||||||
|
readSessionToken,
|
||||||
|
validateNewCredentials,
|
||||||
|
MIN_PASSWORD_LENGTH,
|
||||||
|
} from '../auth';
|
||||||
|
import { toSnakeCase } from '../utils/case';
|
||||||
|
|
||||||
|
// User management (memory-bank/FEATURE_User_Accounts.md). Mounted behind
|
||||||
|
// requireAuth; everyone-equal trust model — any signed-in user can add users,
|
||||||
|
// reset passwords, and remove accounts other than their own. Password hashes
|
||||||
|
// never leave the server.
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
function callerId(req: Request): number {
|
||||||
|
return (req as AuthedRequest).user!.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
const rows = db
|
||||||
|
.select({ id: users.id, username: users.username, createdAt: users.createdAt })
|
||||||
|
.from(users)
|
||||||
|
.orderBy(asc(users.username))
|
||||||
|
.all();
|
||||||
|
res.json(rows.map(toSnakeCase));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
const { username, password } = req.body ?? {};
|
||||||
|
const invalid = validateNewCredentials(username, password);
|
||||||
|
if (invalid) {
|
||||||
|
res.status(400).json({ error: invalid });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case-insensitive duplicate guard (the UNIQUE index is case-sensitive).
|
||||||
|
if (findUserByUsername((username as string).trim())) {
|
||||||
|
res.status(409).json({ error: 'That username is already taken' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = db
|
||||||
|
.insert(users)
|
||||||
|
.values({ username: (username as string).trim(), passwordHash: hashPassword(password as string) })
|
||||||
|
.returning({ id: users.id, username: users.username, createdAt: users.createdAt })
|
||||||
|
.get();
|
||||||
|
res.status(201).json(toSnakeCase(created));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Password reset — also the "someone forgot theirs" path, so no current-password
|
||||||
|
// check (everyone equal). Signs the target out everywhere except the caller's
|
||||||
|
// own current session (relevant when resetting your own password).
|
||||||
|
router.patch('/:id', (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const target = db.select().from(users).where(eq(users.id, id)).get();
|
||||||
|
if (!target) {
|
||||||
|
res.status(404).json({ error: 'User not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password } = req.body ?? {};
|
||||||
|
if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) {
|
||||||
|
res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(users).set({ passwordHash: hashPassword(password) }).where(eq(users.id, id)).run();
|
||||||
|
|
||||||
|
const currentToken = readSessionToken(req);
|
||||||
|
db.delete(authSessions)
|
||||||
|
.where(
|
||||||
|
currentToken
|
||||||
|
? and(eq(authSessions.userId, id), ne(authSessions.token, currentToken))
|
||||||
|
: eq(authSessions.userId, id)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
res.json({ id, username: target.username });
|
||||||
|
});
|
||||||
|
|
||||||
|
// You can't delete yourself — which also means the last account can never be
|
||||||
|
// deleted (you'd have to be signed in as someone else), so no lockout state.
|
||||||
|
router.delete('/:id', (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (id === callerId(req)) {
|
||||||
|
res.status(400).json({ error: 'You cannot delete your own account' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = db.select({ id: users.id }).from(users).where(eq(users.id, id)).get();
|
||||||
|
if (!target) {
|
||||||
|
res.status(404).json({ error: 'User not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit session cleanup: better-sqlite3 runs with foreign_keys OFF, so the
|
||||||
|
// schema's ON DELETE CASCADE is not enforced at runtime.
|
||||||
|
db.transaction((tx) => {
|
||||||
|
tx.delete(authSessions).where(eq(authSessions.userId, id)).run();
|
||||||
|
tx.delete(users).where(eq(users.id, id)).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
Loading…
Reference in New Issue