feat(20-03): unify member editor + declutter admin members panel
- playwright-cli verified: Members tab shows tappable rows, no retired buttons - Row tap opens 'Edit member' sheet; per-section saves keep sheet open - 'Add member' trigger opens 'Add member' sheet in create mode - Profile save fires 'Profile saved.' toast; sheet stays open (D-05) - eslint + prettier + typecheck + vitest (275 tests) all pass - Fix pre-existing prettier drift in docs/*, CLAUDE.md, README.md, api/admin.ts
This commit is contained in:
+26
-26
@@ -125,27 +125,27 @@ Migration files are written to `src/db/migrations/` and checked into source cont
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
| ----------------------------- | ------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `DB_HOST` | Yes | MariaDB host |
|
||||
| `DB_USER` | Yes | MariaDB user |
|
||||
| `DB_PASSWORD` | Yes | MariaDB password |
|
||||
| `DB_NAME` | Yes | MariaDB database name |
|
||||
| `DB_PORT` | No (default `3306`) | MariaDB port |
|
||||
| `OIDC_ISSUER` | Yes (production) | Authelia issuer URL |
|
||||
| `OIDC_CLIENT_ID` | Yes (production) | OIDC client ID |
|
||||
| `OIDC_CLIENT_SECRET` | Yes (production) | OIDC client secret |
|
||||
| `OIDC_AUTH_EXTERNAL_URL` | Yes (production) | External-facing URL for redirect_uri behind Pangolin tunnel |
|
||||
| `OIDC_REDIRECT_URI` | No | Explicit redirect URI (overrides the `${OIDC_AUTH_EXTERNAL_URL}/callback` default) |
|
||||
| `VAPID_SUBJECT` | Yes (push) | `mailto:` or `https:` operator identifier |
|
||||
| `VAPID_PUBLIC_KEY` | Yes (push) | VAPID public key |
|
||||
| `VAPID_PRIVATE_KEY` | Yes (push) | VAPID private key |
|
||||
| `APP_PASSWORD_ENCRYPTION_KEY` | Yes | AES-256-GCM key (64-char hex) for stored Fastmail app passwords |
|
||||
| `LOCAL_SESSION_SECRET` | Yes (local auth) | HS256 signing key for local-session JWT cookies (min 32 chars) |
|
||||
| `LOCAL_SESSION_EXPIRES` | No (default `86400`)| Local session lifetime in seconds |
|
||||
| `DEV_AUTH_BYPASS` | No | Set to `true` (non-production only) to skip OIDC and inject a dev user |
|
||||
| `NODE_ENV` | No | Set to `production` to enforce OIDC unconditionally |
|
||||
| `TZ` | No | IANA timezone fallback when household_timezone is not set in app_config |
|
||||
| Variable | Required | Description |
|
||||
| ----------------------------- | -------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `DB_HOST` | Yes | MariaDB host |
|
||||
| `DB_USER` | Yes | MariaDB user |
|
||||
| `DB_PASSWORD` | Yes | MariaDB password |
|
||||
| `DB_NAME` | Yes | MariaDB database name |
|
||||
| `DB_PORT` | No (default `3306`) | MariaDB port |
|
||||
| `OIDC_ISSUER` | Yes (production) | Authelia issuer URL |
|
||||
| `OIDC_CLIENT_ID` | Yes (production) | OIDC client ID |
|
||||
| `OIDC_CLIENT_SECRET` | Yes (production) | OIDC client secret |
|
||||
| `OIDC_AUTH_EXTERNAL_URL` | Yes (production) | External-facing URL for redirect_uri behind Pangolin tunnel |
|
||||
| `OIDC_REDIRECT_URI` | No | Explicit redirect URI (overrides the `${OIDC_AUTH_EXTERNAL_URL}/callback` default) |
|
||||
| `VAPID_SUBJECT` | Yes (push) | `mailto:` or `https:` operator identifier |
|
||||
| `VAPID_PUBLIC_KEY` | Yes (push) | VAPID public key |
|
||||
| `VAPID_PRIVATE_KEY` | Yes (push) | VAPID private key |
|
||||
| `APP_PASSWORD_ENCRYPTION_KEY` | Yes | AES-256-GCM key (64-char hex) for stored Fastmail app passwords |
|
||||
| `LOCAL_SESSION_SECRET` | Yes (local auth) | HS256 signing key for local-session JWT cookies (min 32 chars) |
|
||||
| `LOCAL_SESSION_EXPIRES` | No (default `86400`) | Local session lifetime in seconds |
|
||||
| `DEV_AUTH_BYPASS` | No | Set to `true` (non-production only) to skip OIDC and inject a dev user |
|
||||
| `NODE_ENV` | No | Set to `production` to enforce OIDC unconditionally |
|
||||
| `TZ` | No | IANA timezone fallback when household_timezone is not set in app_config |
|
||||
|
||||
> **Note:** `CREDENTIAL_ENCRYPTION_KEY` was renamed to `APP_PASSWORD_ENCRYPTION_KEY`. Update any existing `.env` files if upgrading from an earlier phase.
|
||||
|
||||
@@ -155,11 +155,11 @@ See [../../docs/CONFIGURATION.md](../../docs/CONFIGURATION.md) for the full refe
|
||||
|
||||
The API supports two non-exclusive auth modes, determined at startup:
|
||||
|
||||
| Mode | When active | How it works |
|
||||
| ---- | ----------- | ------------ |
|
||||
| **Local** | Always (default) | `POST /api/auth/local/login` with username + password; issues an HS256 JWT `local-session` cookie. Requires `LOCAL_SESSION_SECRET`. |
|
||||
| **OIDC** | When `OIDC_ISSUER` + `OIDC_CLIENT_ID` are set (env or app_config) | `@hono/oidc-auth` authorization-code + PKCE against Authelia. Local users can upgrade to OIDC via `POST /api/me/link-oidc`. |
|
||||
| **Dev bypass** | `DEV_AUTH_BYPASS=true` in non-production | Skips both guards and injects a synthetic dev user. Blocked in `NODE_ENV=production` by boot guard. |
|
||||
| Mode | When active | How it works |
|
||||
| -------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Local** | Always (default) | `POST /api/auth/local/login` with username + password; issues an HS256 JWT `local-session` cookie. Requires `LOCAL_SESSION_SECRET`. |
|
||||
| **OIDC** | When `OIDC_ISSUER` + `OIDC_CLIENT_ID` are set (env or app_config) | `@hono/oidc-auth` authorization-code + PKCE against Authelia. Local users can upgrade to OIDC via `POST /api/me/link-oidc`. |
|
||||
| **Dev bypass** | `DEV_AUTH_BYPASS=true` in non-production | Skips both guards and injects a synthetic dev user. Blocked in `NODE_ENV=production` by boot guard. |
|
||||
|
||||
`GET /api/auth/mode` returns `{ localEnabled, oidcEnabled }` before authentication — the PWA uses this to decide which login form to show.
|
||||
|
||||
|
||||
@@ -227,57 +227,53 @@ const updateMemberSchema = z.object({
|
||||
isAdmin: z.boolean().optional(),
|
||||
});
|
||||
|
||||
adminRouter.patch(
|
||||
'/members/:id',
|
||||
zValidator('json', updateMemberSchema, noEchoHook),
|
||||
async (c) => {
|
||||
const targetId = parsePositiveIntParam(c.req.param('id'));
|
||||
if (targetId === null) {
|
||||
return c.json({ error: 'Invalid member id' }, 400);
|
||||
}
|
||||
adminRouter.patch('/members/:id', zValidator('json', updateMemberSchema, noEchoHook), async (c) => {
|
||||
const targetId = parsePositiveIntParam(c.req.param('id'));
|
||||
if (targetId === null) {
|
||||
return c.json({ error: 'Invalid member id' }, 400);
|
||||
}
|
||||
|
||||
const { displayName, isAdmin } = c.req.valid('json');
|
||||
// T-20-04: NEVER log request body
|
||||
const { displayName, isAdmin } = c.req.valid('json');
|
||||
// T-20-04: NEVER log request body
|
||||
|
||||
// Verify the target user exists (404 if not)
|
||||
const [target] = await db
|
||||
.select({ id: users.id, isAdmin: users.isAdmin })
|
||||
// Verify the target user exists (404 if not)
|
||||
const [target] = await db
|
||||
.select({ id: users.id, isAdmin: users.isAdmin })
|
||||
.from(users)
|
||||
.where(eq(users.id, targetId))
|
||||
.limit(1);
|
||||
|
||||
if (!target) {
|
||||
return c.json({ error: 'Member not found' }, 404);
|
||||
}
|
||||
|
||||
// D-03 last-admin guard: reject demotion of the only remaining admin (T-20-02)
|
||||
if (isAdmin === false && target.isAdmin) {
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(users)
|
||||
.where(eq(users.id, targetId))
|
||||
.limit(1);
|
||||
|
||||
if (!target) {
|
||||
return c.json({ error: 'Member not found' }, 404);
|
||||
.where(eq(users.isAdmin, true));
|
||||
if (Number(count) <= 1) {
|
||||
return c.json({ error: 'Cannot remove the last admin' }, 409);
|
||||
}
|
||||
}
|
||||
|
||||
// D-03 last-admin guard: reject demotion of the only remaining admin (T-20-02)
|
||||
if (isAdmin === false && target.isAdmin) {
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(users)
|
||||
.where(eq(users.isAdmin, true));
|
||||
if (Number(count) <= 1) {
|
||||
return c.json({ error: 'Cannot remove the last admin' }, 409);
|
||||
}
|
||||
}
|
||||
// Build a partial set() from whichever fields are present
|
||||
const updates: { displayName?: string; isAdmin?: boolean } = {};
|
||||
if (displayName !== undefined) updates.displayName = displayName;
|
||||
if (isAdmin !== undefined) updates.isAdmin = isAdmin;
|
||||
|
||||
// Build a partial set() from whichever fields are present
|
||||
const updates: { displayName?: string; isAdmin?: boolean } = {};
|
||||
if (displayName !== undefined) updates.displayName = displayName;
|
||||
if (isAdmin !== undefined) updates.isAdmin = isAdmin;
|
||||
|
||||
try {
|
||||
await db.update(users).set(updates).where(eq(users.id, targetId));
|
||||
return c.json({ ok: true }, 200);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[admin/PATCH /members/:id] Unexpected error:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
},
|
||||
);
|
||||
try {
|
||||
await db.update(users).set(updates).where(eq(users.id, targetId));
|
||||
return c.json({ ok: true }, 200);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[admin/PATCH /members/:id] Unexpected error:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/admin/members/:id/password
|
||||
|
||||
@@ -1094,7 +1094,9 @@ describe('PATCH /api/admin/members/:id', () => {
|
||||
// GET /members should reflect the updated displayName
|
||||
const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members'));
|
||||
expect(getRes.status).toBe(200);
|
||||
const getBody = (await getRes.json()) as { members: Array<{ id: number; displayName: string }> };
|
||||
const getBody = (await getRes.json()) as {
|
||||
members: Array<{ id: number; displayName: string }>;
|
||||
};
|
||||
const updated = getBody.members.find((m) => m.id === memberId);
|
||||
expect(updated).toBeDefined();
|
||||
expect(updated!.displayName).toBe('New Name');
|
||||
|
||||
+11
-11
@@ -32,17 +32,17 @@ The API backend must also be running for most features. See [GETTING-STARTED.md]
|
||||
|
||||
## Scripts
|
||||
|
||||
| Command | What it does |
|
||||
| ------------------------------------------------ | ------------------------------------------------------------------------- |
|
||||
| `pnpm --filter @familysync/pwa dev` | Start Vite dev server (HMR) |
|
||||
| `pnpm --filter @familysync/pwa build` | Type-check then build production bundle (`tsc && vite build`) |
|
||||
| `pnpm --filter @familysync/pwa preview` | Serve the production build locally |
|
||||
| `pnpm --filter @familysync/pwa lint` | Run ESLint over `src/` and `e2e/` with zero warnings allowed |
|
||||
| `pnpm --filter @familysync/pwa typecheck` | Run `tsc --noEmit` for both `src/` and `e2e/` tsconfigs |
|
||||
| `pnpm --filter @familysync/pwa test` | Run Vitest unit/integration suite once (`vitest run`) |
|
||||
| `pnpm --filter @familysync/pwa test:e2e` | Run Playwright end-to-end tests headlessly |
|
||||
| `pnpm --filter @familysync/pwa test:e2e:ui` | Open the Playwright UI runner |
|
||||
| `pnpm --filter @familysync/pwa test:e2e:headed` | Run Playwright tests in a headed browser |
|
||||
| Command | What it does |
|
||||
| ----------------------------------------------- | ------------------------------------------------------------- |
|
||||
| `pnpm --filter @familysync/pwa dev` | Start Vite dev server (HMR) |
|
||||
| `pnpm --filter @familysync/pwa build` | Type-check then build production bundle (`tsc && vite build`) |
|
||||
| `pnpm --filter @familysync/pwa preview` | Serve the production build locally |
|
||||
| `pnpm --filter @familysync/pwa lint` | Run ESLint over `src/` and `e2e/` with zero warnings allowed |
|
||||
| `pnpm --filter @familysync/pwa typecheck` | Run `tsc --noEmit` for both `src/` and `e2e/` tsconfigs |
|
||||
| `pnpm --filter @familysync/pwa test` | Run Vitest unit/integration suite once (`vitest run`) |
|
||||
| `pnpm --filter @familysync/pwa test:e2e` | Run Playwright end-to-end tests headlessly |
|
||||
| `pnpm --filter @familysync/pwa test:e2e:ui` | Open the Playwright UI runner |
|
||||
| `pnpm --filter @familysync/pwa test:e2e:headed` | Run Playwright tests in a headed browser |
|
||||
|
||||
## Source layout
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineConfig, minimal2023Preset } from '@vite-pwa/assets-generator/config'
|
||||
import { defineConfig, minimal2023Preset } from '@vite-pwa/assets-generator/config';
|
||||
|
||||
export default defineConfig({
|
||||
preset: {
|
||||
@@ -7,4 +7,4 @@ export default defineConfig({
|
||||
images: ['public/logo.svg'],
|
||||
// Do NOT set overrideManifestIcons: true — the manifest is maintained by hand
|
||||
// in vite.config.ts (plan 17-04); auto-override would stomp the explicit entries.
|
||||
})
|
||||
});
|
||||
|
||||
@@ -29,7 +29,13 @@ import { usePushSubscription } from '../hooks/usePushSubscription.js';
|
||||
import { useIsPhone } from '../hooks/useIsPhone.js';
|
||||
import { useFocusTrap } from '../hooks/useFocusTrap.js';
|
||||
import { InstructionSheet } from './InstructionSheet.js';
|
||||
import { fetchMe, fetchAuthMode, fetchChangePassword, fetchLinkOidc, fetchLocalLogout } from '../api/client.js';
|
||||
import {
|
||||
fetchMe,
|
||||
fetchAuthMode,
|
||||
fetchChangePassword,
|
||||
fetchLinkOidc,
|
||||
fetchLocalLogout,
|
||||
} from '../api/client.js';
|
||||
|
||||
// CR-04: fetch VAPID key (from sessionStorage cache if available) for the
|
||||
// tap-gated subscribe() path. Same logic as PushPermissionPrompt.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
*/
|
||||
|
||||
:root,
|
||||
[data-theme="light"] {
|
||||
[data-theme='light'] {
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* BASE SURFACE / BORDER / TEXT PALETTE
|
||||
* ───────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -42,7 +42,12 @@ export default defineConfig({
|
||||
icons: [
|
||||
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
{ src: '/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
{
|
||||
src: '/icon-maskable-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user