Add Playwright e2e coverage + comment-level video upload workflow #40

Merged
shaun merged 7 commits from ai-agent/38-allow-administrators-to-create-local-login-users into main 2026-02-22 21:20:20 +00:00
22 changed files with 993 additions and 18 deletions

21
.env.test Normal file
View File

@@ -0,0 +1,21 @@
# E2E test environment — used by the dev server during Playwright runs.
# Never touches trips.db.
AUTH_URL=http://127.0.0.1:5173/trips/auth
AUTH_SECRET=e2e-test-secret-do-not-use-in-production-32b
LOCAL_AUTH_ENABLED=true
LOCAL_AUTH_ARGON2_MEMORY_KB=8192
LOCAL_AUTH_ARGON2_TIME_COST=2
LOCAL_AUTH_ARGON2_PARALLELISM=1
# e2e_admin username is matched by isAdminUser() via DB lookup
ADMIN_USER_IDS=e2e_admin
# Separate test database — never touches trips.db
DATABASE_URL=file:trips.test.db
# Synology OIDC — not exercised in E2E tests but must be present to avoid startup errors
SYNOLOGY_ISSUER=https://cloud.campbellwireless.net/auth/webman/sso
SYNOLOGY_CLIENT_ID=e2e-placeholder
SYNOLOGY_CLIENT_SECRET=e2e-placeholder

View File

@@ -31,6 +31,14 @@
- Framework: Vitest with `node` environment. - Framework: Vitest with `node` environment.
- Test file pattern: `src/**/*.test.ts`. - Test file pattern: `src/**/*.test.ts`.
- Keep tests near the code they cover; use `src/tests/stubs` for runtime stubbing. - Keep tests near the code they cover; use `src/tests/stubs` for runtime stubbing.
- For every user-facing feature or behavior change, add/update Playwright e2e coverage under `e2e/`.
- When work is tied to a Gitea issue, record an e2e run video and upload it to that issue.
- One-off video run: `PW_VIDEO_MODE=on PW_TRACE_MODE=on bunx playwright test <spec>`
- Prefer comment-level attachments: post a comment first, then upload the video to that comment.
- Create comment: `tea comment -l <login> -r <owner>/<repo> <issue-index> "<message>"`
- Upload with `curl` (example, `tea api` in this repo's toolchain does not send multipart correctly):
- `TOKEN=$(awk '/- name: cloud.campbellwireless.net/{f=1} f && $1=="token:"{print $2; exit}' "$HOME/Library/Application Support/tea/config.yml")`
- `curl -fsS -X POST "https://cloud.campbellwireless.net/git/api/v1/repos/{owner}/{repo}/issues/comments/{comment_id}/assets" -H "Authorization: token $TOKEN" -F "name=<filename>" -F "attachment=@<path>"`
## Commit & Pull Request Guidelines ## Commit & Pull Request Guidelines
- Commit messages currently follow a light “scope) message” pattern, e.g., - Commit messages currently follow a light “scope) message” pattern, e.g.,

View File

@@ -28,6 +28,18 @@ After making changes, always verify:
1. `bun run lint` — must exit with 0 errors (warnings are acceptable) 1. `bun run lint` — must exit with 0 errors (warnings are acceptable)
2. `bun run test` — all tests must pass 2. `bun run test` — all tests must pass
For user-facing feature work, also add/update e2e coverage and validate it:
3. `bunx playwright test <target spec or suite>`
When work maps to a Gitea issue, upload an e2e run video to the issue:
- Generate one-off video artifacts with:
- `PW_VIDEO_MODE=on PW_TRACE_MODE=on bunx playwright test <target spec>`
- Prefer comment-level attachments: create a comment first, then attach video to that comment.
- `tea comment -l <login> -r <owner>/<repo> <issue-index> "<message>"`
- Upload with `curl` (the current `tea api` build here does not send multipart/form-data correctly for attachments):
- `TOKEN=$(awk '/- name: cloud.campbellwireless.net/{f=1} f && $1=="token:"{print $2; exit}' "$HOME/Library/Application Support/tea/config.yml")`
- `curl -fsS -X POST "https://cloud.campbellwireless.net/git/api/v1/repos/{owner}/{repo}/issues/comments/{comment_id}/assets" -H "Authorization: token $TOKEN" -F "name=<filename>" -F "attachment=@<path>"`
### Write unit tests after every major feature ### Write unit tests after every major feature
When adding or significantly modifying server-side business logic (files under `src/lib/server/`), write corresponding unit tests in a `.test.ts` file alongside the module (e.g. `src/lib/server/lodgings.test.ts`). When adding or significantly modifying server-side business logic (files under `src/lib/server/`), write corresponding unit tests in a `.test.ts` file alongside the module (e.g. `src/lib/server/lodgings.test.ts`).

View File

@@ -11,12 +11,14 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.4", "@biomejs/biome": "^2.4.4",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.58.2",
"@sveltejs/adapter-node": "^5.5.3", "@sveltejs/adapter-node": "^5.5.3",
"@sveltejs/kit": "^2.50.2", "@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.0", "@tailwindcss/vite": "^4.2.0",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"bun-types": "^1.3.9", "bun-types": "^1.3.9",
"dotenv": "^17.3.1",
"eslint": "^10.0.0", "eslint": "^10.0.0",
"eslint-plugin-svelte": "^3.15.0", "eslint-plugin-svelte": "^3.15.0",
"globals": "^17.3.0", "globals": "^17.3.0",
@@ -156,6 +158,8 @@
"@phc/format": ["@phc/format@1.0.0", "", {}, "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ=="], "@phc/format": ["@phc/format@1.0.0", "", {}, "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ=="],
"@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@29.0.0", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-U2YHaxR2cU/yAiwKJtJRhnyLk7cifnQw0zUpISsocBDoHDJn+HTV74ABqnwr5bEgWUwFZC9oFL6wLe21lHu5eQ=="], "@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@29.0.0", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-U2YHaxR2cU/yAiwKJtJRhnyLk7cifnQw0zUpISsocBDoHDJn+HTV74ABqnwr5bEgWUwFZC9oFL6wLe21lHu5eQ=="],
@@ -358,6 +362,8 @@
"devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="], "devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="],
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
@@ -540,6 +546,10 @@
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-load-config": ["postcss-load-config@3.1.4", "", { "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg=="], "postcss-load-config": ["postcss-load-config@3.1.4", "", { "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg=="],
@@ -674,6 +684,8 @@
"eslint-plugin-svelte/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], "eslint-plugin-svelte/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"svelte-eslint-parser/eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], "svelte-eslint-parser/eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"svelte-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], "svelte-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],

56
e2e/auth.test.ts Normal file
View File

@@ -0,0 +1,56 @@
import { test, expect, type Page } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js';
const LOGIN_URL = '/trips/login';
const DASHBOARD_URL = '/trips/dashboard';
const ADMIN_USERS_URL = '/trips/admin/users';
async function loginAsLocalUser(page: Page, username: string, password: string): Promise<void> {
await page.goto(LOGIN_URL);
await page.fill('input[name="identifier"]', username);
await page.fill('input[name="password"]', password);
await page.click('button[type="submit"]:has-text("Sign in locally")');
await page.waitForURL(`**${DASHBOARD_URL}`, { timeout: 15_000 });
}
test.beforeEach(async ({ context }) => {
await context.clearCookies();
});
test.describe('regular user', () => {
test('can log in and lands on dashboard', async ({ page }) => {
await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await expect(page).toHaveURL(/\/trips\/dashboard/);
});
test('cannot access admin — redirected to dashboard', async ({ page }) => {
await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await page.goto(ADMIN_USERS_URL);
await expect(page).toHaveURL(/\/trips\/dashboard/);
});
});
test.describe('admin user', () => {
test('can log in and lands on dashboard', async ({ page }) => {
await loginAsLocalUser(page, TEST_USERS.admin.username, TEST_USERS.admin.password);
await expect(page).toHaveURL(/\/trips\/dashboard/);
});
test('can access /admin/users', async ({ page }) => {
await loginAsLocalUser(page, TEST_USERS.admin.username, TEST_USERS.admin.password);
await page.goto(ADMIN_USERS_URL);
await expect(page).toHaveURL(/\/trips\/admin\/users/);
await expect(page.locator('h1')).toContainText('Users');
});
});
test.describe('error handling', () => {
test('bad password shows error on login page', async ({ page }) => {
await page.goto(LOGIN_URL);
await page.fill('input[name="identifier"]', TEST_USERS.regular.username);
await page.fill('input[name="password"]', 'wrong-password-long-enough');
await page.click('button[type="submit"]:has-text("Sign in locally")');
await page.waitForURL(/error=CredentialsSignin/, { timeout: 10_000 });
await expect(page.locator('.text-rose-700')).toContainText('Invalid credentials');
});
});

20
e2e/setup/global-setup.ts Normal file
View File

@@ -0,0 +1,20 @@
import type { FullConfig } from '@playwright/test';
import { spawnSync } from 'child_process';
import { resolve } from 'path';
import { config as dotenvConfig } from 'dotenv';
dotenvConfig({ path: resolve(process.cwd(), '.env.test'), override: true });
async function globalSetup(_config: FullConfig): Promise<void> {
console.log('[e2e] Seeding test database via Bun...');
const result = spawnSync('bun', ['run', 'e2e/setup/seed-db.ts'], {
env: { ...process.env },
stdio: 'inherit',
cwd: process.cwd()
});
if (result.status !== 0) {
throw new Error(`[e2e] DB seed script failed with exit code ${result.status}`);
}
}
export default globalSetup;

View File

@@ -0,0 +1,17 @@
import type { FullConfig } from '@playwright/test';
import { existsSync, unlinkSync } from 'fs';
import { resolve } from 'path';
const DB_PATH = resolve(process.cwd(), 'trips.test.db');
async function globalTeardown(_config: FullConfig): Promise<void> {
for (const ext of ['', '-shm', '-wal']) {
const p = DB_PATH + ext;
if (existsSync(p)) {
unlinkSync(p);
}
}
console.log('[e2e] Removed trips.test.db');
}
export default globalTeardown;

77
e2e/setup/seed-db.ts Normal file
View File

@@ -0,0 +1,77 @@
// Bun script — runs with `bun run e2e/setup/seed-db.ts`.
// Uses bun:sqlite and argon2 directly; must NOT be imported from Node context.
import { Database as BunSqlite } from 'bun:sqlite';
import { existsSync, unlinkSync } from 'fs';
import { resolve } from 'path';
import argon2 from 'argon2';
import { randomUUID } from 'crypto';
import { config as dotenvConfig } from 'dotenv';
import { TEST_USERS } from './test-users.js';
dotenvConfig({ path: resolve(process.cwd(), '.env.test'), override: true });
const DB_PATH = resolve(process.cwd(), 'trips.test.db');
// Delete any existing test DB so we start clean each run
for (const ext of ['', '-shm', '-wal']) {
const p = DB_PATH + ext;
if (existsSync(p)) {
unlinkSync(p);
}
}
const db = new BunSqlite(DB_PATH);
db.exec('PRAGMA foreign_keys = ON');
// Wrap bun:sqlite to match the Database interface expected by runMigrations
const dbWrapper = {
run(sql: string, params: unknown[] = []) {
db.query(sql).run(...(params as [unknown?, ...unknown[]]));
},
get<T = Record<string, unknown>>(sql: string, params: unknown[] = []): T | undefined {
const row = db.query(sql).get(...(params as [unknown?, ...unknown[]]));
return (row === null ? undefined : row) as T | undefined;
},
all<T = Record<string, unknown>>(sql: string, params: unknown[] = []): T[] {
return db.query(sql).all(...(params as [unknown?, ...unknown[]])) as T[];
},
close() {
db.close();
}
};
const { runMigrations } = await import('../../src/lib/server/db/migrations.js');
runMigrations(dbWrapper);
console.log('[e2e] Migrations complete.');
async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 8192,
timeCost: 2,
parallelism: 1
});
}
const regularId = randomUUID();
dbWrapper.run(
`INSERT INTO users (id, username, full_name, email, auth_source) VALUES (?, ?, ?, ?, ?)`,
[regularId, TEST_USERS.regular.username, TEST_USERS.regular.fullName, TEST_USERS.regular.email, 'Local']
);
dbWrapper.run(`INSERT INTO local_credentials (user_id, password_hash) VALUES (?, ?)`, [
regularId,
await hashPassword(TEST_USERS.regular.password)
]);
const adminId = randomUUID();
dbWrapper.run(
`INSERT INTO users (id, username, full_name, email, auth_source) VALUES (?, ?, ?, ?, ?)`,
[adminId, TEST_USERS.admin.username, TEST_USERS.admin.fullName, TEST_USERS.admin.email, 'Local']
);
dbWrapper.run(`INSERT INTO local_credentials (user_id, password_hash) VALUES (?, ?)`, [
adminId,
await hashPassword(TEST_USERS.admin.password)
]);
db.close();
console.log(`[e2e] Seeded users: ${TEST_USERS.regular.username}, ${TEST_USERS.admin.username}`);

15
e2e/setup/test-users.ts Normal file
View File

@@ -0,0 +1,15 @@
// Shared test user definitions — no runtime deps, importable from both Node and Bun contexts.
export const TEST_USERS = {
regular: {
username: 'e2e_user',
fullName: 'E2E Regular User',
email: 'e2e_user@test.local',
password: 'e2e-regular-password123'
},
admin: {
username: 'e2e_admin',
fullName: 'E2E Admin User',
email: 'e2e_admin@test.local',
password: 'e2e-admin-password123'
}
} as const;

186
e2e/trip-planning.test.ts Normal file
View File

@@ -0,0 +1,186 @@
import { test, expect, type Locator, type Page } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js';
const LOGIN_URL = '/trips/login';
const PROFILE_URL = '/trips/profile';
function formatDate(offsetDays: number): string {
const date = new Date();
date.setDate(date.getDate() + offsetDays);
return date.toISOString().slice(0, 10);
}
async function loginAsLocalUser(page: Page, username: string, password: string): Promise<void> {
await page.goto(LOGIN_URL);
await page.fill('input[name="identifier"]', username);
await page.fill('input[name="password"]', password);
await page.click('button[type="submit"]:has-text("Sign in locally")');
await page.waitForURL('**/trips/dashboard', { timeout: 15_000 });
}
async function ensureSelfProfile(page: Page): Promise<void> {
await page.goto(PROFILE_URL);
await page.fill('#first_name', 'E2E');
await page.fill('#last_name', 'User');
await page.fill('#email', TEST_USERS.regular.email);
await page.getByRole('button', { name: 'Save profile' }).click();
await expect(page.getByRole('heading', { name: 'My Profile' })).toBeVisible();
}
async function openAddToTripMenuItem(page: Page, label: string): Promise<void> {
await page.getByRole('button', { name: 'Add to trip' }).click();
await page.getByRole('button', { name: label, exact: true }).click();
}
async function addLoggedInTraveller(page: Page): Promise<void> {
await page.getByRole('button', { name: "Who's travelling?" }).click();
const dialog = page.getByRole('dialog', { name: 'Add traveller' });
await expect(dialog).toBeVisible();
const selfButton = dialog.getByRole('button', { name: /You/ }).first();
if (await selfButton.isVisible().catch(() => false)) {
await selfButton.click();
} else {
const firstNameInput = dialog.getByLabel('First name *');
if (!(await firstNameInput.isVisible().catch(() => false))) {
await dialog.getByRole('button', { name: 'Add someone new' }).click();
}
await dialog.getByLabel('First name *').fill('E2E');
await dialog.getByLabel('Last name *').fill('User');
await dialog.getByLabel(/Email/).fill(TEST_USERS.regular.email);
await dialog.getByRole('button', { name: 'Add traveller' }).click();
}
await expect(dialog).toBeHidden();
}
async function addNewTraveller(
page: Page,
firstName: string,
lastName: string,
email: string
): Promise<void> {
await openAddToTripMenuItem(page, 'Travellers');
const dialog = page.getByRole('dialog', { name: 'Add traveller' });
await expect(dialog).toBeVisible();
const firstNameInput = dialog.getByLabel('First name *');
if (!(await firstNameInput.isVisible().catch(() => false))) {
await dialog.getByRole('button', { name: 'Add someone new' }).click();
}
await dialog.getByLabel('First name *').fill(firstName);
await dialog.getByLabel('Last name *').fill(lastName);
await dialog.getByLabel(/Email/).fill(email);
await dialog.getByRole('button', { name: 'Add traveller' }).click();
await expect(dialog).toBeHidden();
}
async function addDestination(page: Page, cityQuery: string, startDate: string): Promise<void> {
await openAddToTripMenuItem(page, 'Destinations');
const dialog = page.getByRole('dialog', { name: 'Add destination' });
await expect(dialog).toBeVisible();
await dialog.getByLabel('City *').fill(cityQuery);
const cityOption = dialog.locator('ul button').filter({ hasText: cityQuery }).first();
await expect(cityOption).toBeVisible();
await cityOption.click();
await dialog.getByLabel('Arrival').fill(startDate);
await dialog.getByRole('button', { name: 'Add to trip' }).click();
await expect(dialog).toBeHidden();
}
async function addFlight(page: Page, departureDate: string): Promise<void> {
await openAddToTripMenuItem(page, 'Transportation');
const transportDialog = page.getByRole('dialog', { name: 'Add transportation' });
await expect(transportDialog).toBeVisible();
await transportDialog.getByRole('button', { name: /Flight/ }).click();
const form = transportDialog.locator('form');
await expect(form.locator('input[name="segments[0][departure_date]"]')).toBeVisible();
await form.locator('input[name="segments[0][departure_date]"]').fill(departureDate);
await form.getByPlaceholder('Search airline or enter code').fill('UA');
await form.locator('input[name="segments[0][flight_number]"]').fill('1001');
await form.getByPlaceholder('Code or search').nth(0).fill('SFO');
await form.getByPlaceholder('Code or search').nth(1).fill('LAX');
await expect(form.getByRole('button', { name: 'Add transportation' })).toBeEnabled();
await form.getByRole('button', { name: 'Add transportation' }).click();
await expect(transportDialog).toBeHidden();
}
async function addLodging(page: Page, lodgingName: string): Promise<void> {
await openAddToTripMenuItem(page, 'Lodgings');
const dialog = page.getByRole('dialog', { name: 'Add lodging' });
await expect(dialog).toBeVisible();
await dialog.getByLabel('Name *').fill(lodgingName);
await dialog.getByRole('button', { name: 'Add lodging' }).click();
await expect(dialog).toBeHidden();
}
async function expectPlanSectionsAndDetails(
page: Page,
tripName: string,
destinationQuery: string,
flightText: RegExp,
lodgingName: string,
travellerLocators: Locator[]
): Promise<void> {
await page.getByRole('link', { name: 'Upcoming Trips' }).click();
await expect(page.getByRole('heading', { name: 'Upcoming Trips' })).toBeVisible();
await expect(page.getByRole('link', { name: tripName })).toBeVisible();
await page.getByRole('link', { name: tripName }).click();
await expect(page.getByRole('heading', { name: tripName })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Destinations' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Transportation' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Lodgings' })).toBeVisible();
await expect(page.getByText(destinationQuery, { exact: false })).toBeVisible();
await expect(page.getByText(flightText)).toBeVisible();
await expect(page.getByText(lodgingName, { exact: true })).toBeVisible();
for (const traveller of travellerLocators) {
await expect(traveller).toBeVisible();
}
}
test.beforeEach(async ({ context }) => {
await context.clearCookies();
});
test('regular user can plan a detailed future trip and see it in upcoming', async ({ page }) => {
const startDate = formatDate(30);
const tripName = `E2E Future Trip ${Date.now()}`;
const tripDescription = 'Future trip created in Playwright e2e scenario';
const destinationQuery = 'Tokyo';
const lodgingName = `E2E Hotel ${Date.now()}`;
const newTravellerFirstName = 'Jamie';
const newTravellerLastName = 'Companion';
const newTravellerEmail = `jamie+${Date.now()}@test.local`;
await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await ensureSelfProfile(page);
await page.getByRole('link', { name: 'Plan New Trip' }).click();
await expect(page.getByRole('heading', { name: 'Plan New Trip' })).toBeVisible();
await page.getByLabel('Trip name *').fill(tripName);
await page.getByLabel('Start date').fill(startDate);
await page.getByLabel('End date').fill('');
await page.getByLabel('Description').fill(tripDescription);
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForURL('**/trips/trips/*', { timeout: 15_000 });
await expect(page.getByRole('heading', { name: tripName })).toBeVisible();
await addLoggedInTraveller(page);
await addNewTraveller(page, newTravellerFirstName, newTravellerLastName, newTravellerEmail);
await addDestination(page, destinationQuery, startDate);
await addFlight(page, startDate);
await addLodging(page, lodgingName);
await expectPlanSectionsAndDetails(page, tripName, destinationQuery, /UA\s*1001/, lodgingName, [
page.getByText('E2E User', { exact: false }),
page.getByText(`${newTravellerFirstName} ${newTravellerLastName}`, { exact: false })
]);
});

View File

@@ -14,17 +14,22 @@
"format": "prettier --write .", "format": "prettier --write .",
"test": "bunx --bun svelte-kit sync && bunx --bun vitest run", "test": "bunx --bun svelte-kit sync && bunx --bun vitest run",
"test:watch": "bunx --bun svelte-kit sync && bunx --bun vitest", "test:watch": "bunx --bun svelte-kit sync && bunx --bun vitest",
"test:coverage": "vitest run --coverage" "test:coverage": "vitest run --coverage",
"test:e2e": "bunx playwright test",
"test:e2e:ui": "bunx playwright test --ui",
"test:e2e:debug": "bunx playwright test --debug"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.4", "@biomejs/biome": "^2.4.4",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.58.2",
"@sveltejs/adapter-node": "^5.5.3", "@sveltejs/adapter-node": "^5.5.3",
"@sveltejs/kit": "^2.50.2", "@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.0", "@tailwindcss/vite": "^4.2.0",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"bun-types": "^1.3.9", "bun-types": "^1.3.9",
"dotenv": "^17.3.1",
"eslint": "^10.0.0", "eslint": "^10.0.0",
"eslint-plugin-svelte": "^3.15.0", "eslint-plugin-svelte": "^3.15.0",
"globals": "^17.3.0", "globals": "^17.3.0",

53
playwright.config.ts Normal file
View File

@@ -0,0 +1,53 @@
import { defineConfig, devices } from '@playwright/test';
import { config as dotenvConfig } from 'dotenv';
import { resolve } from 'path';
// Load .env.test so both this process and the webServer child process see the values.
dotenvConfig({ path: resolve(process.cwd(), '.env.test'), override: true });
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: 1,
reporter: 'html',
use: {
baseURL: 'http://127.0.0.1:5173',
trace: process.env.PW_TRACE_MODE ?? 'on-first-retry',
video: process.env.PW_VIDEO_MODE ?? 'off'
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
}
],
globalTeardown: './e2e/setup/global-teardown.ts',
webServer: {
// Playwright starts webServer before globalSetup. Seed first so the app
// process opens the final DB file and never sees it replaced underneath.
command: 'bun run e2e/setup/seed-db.ts && bunx --bun vite dev --host 127.0.0.1',
url: 'http://127.0.0.1:5173/trips',
// Always start a fresh server to ensure it uses trips.test.db
reuseExistingServer: false,
timeout: 120_000,
env: {
AUTH_URL: process.env.AUTH_URL!,
AUTH_SECRET: process.env.AUTH_SECRET!,
LOCAL_AUTH_ENABLED: process.env.LOCAL_AUTH_ENABLED!,
LOCAL_AUTH_ARGON2_MEMORY_KB: process.env.LOCAL_AUTH_ARGON2_MEMORY_KB!,
LOCAL_AUTH_ARGON2_TIME_COST: process.env.LOCAL_AUTH_ARGON2_TIME_COST!,
LOCAL_AUTH_ARGON2_PARALLELISM: process.env.LOCAL_AUTH_ARGON2_PARALLELISM!,
ADMIN_USER_IDS: process.env.ADMIN_USER_IDS!,
DATABASE_URL: process.env.DATABASE_URL!,
SYNOLOGY_ISSUER: process.env.SYNOLOGY_ISSUER!,
SYNOLOGY_CLIENT_ID: process.env.SYNOLOGY_CLIENT_ID!,
SYNOLOGY_CLIENT_SECRET: process.env.SYNOLOGY_CLIENT_SECRET!
}
}
});

View File

@@ -47,6 +47,9 @@ export const { handle, signIn, signOut } = SvelteKitAuth({
}) })
], ],
trustHost: true, trustHost: true,
pages: {
signIn: '/trips/login'
},
callbacks: { callbacks: {
jwt({ token, profile, user }) { jwt({ token, profile, user }) {
const details = profile as const details = profile as

View File

@@ -2,8 +2,10 @@ import { beforeEach, afterEach, describe, expect, it } from 'vitest';
import { setupTestDb } from '../../tests/helpers.js'; import { setupTestDb } from '../../tests/helpers.js';
import type { Database } from './db/types.js'; import type { Database } from './db/types.js';
import { import {
createLocalUser,
deleteUser, deleteUser,
listUsers, listUsers,
setLocalPasswordForUser,
updateUser, updateUser,
upsertUserFromAuth, upsertUserFromAuth,
upsertUserProfile upsertUserProfile
@@ -100,3 +102,50 @@ describe('deleteUser', () => {
expect(listUsers().find((item) => item.id === 'u4')).toBeUndefined(); expect(listUsers().find((item) => item.id === 'u4')).toBeUndefined();
}); });
}); });
describe('createLocalUser', () => {
it('creates a local user with credentials', async () => {
const created = await createLocalUser({
username: 'local-user',
fullName: 'Local User',
email: 'local@example.com',
password: 'averysecurepassword'
});
expect(created.username).toBe('local-user');
expect(created.auth_source).toBe('Local');
const row = database.get<{ user_id: string; password_hash: string }>(
'SELECT user_id, password_hash FROM local_credentials WHERE user_id = ?',
[created.id]
);
expect(row?.user_id).toBe(created.id);
expect(row?.password_hash).toBeTruthy();
});
it('rejects duplicate usernames', async () => {
await createLocalUser({
username: 'dup-user',
fullName: 'Dup User',
password: 'averysecurepassword'
});
await expect(
createLocalUser({
username: 'dup-user',
fullName: 'Dup User Two',
password: 'averysecurepassword'
})
).rejects.toThrow('Username already exists');
});
});
describe('setLocalPasswordForUser', () => {
it('sets local credentials for an existing user', async () => {
upsertUserFromAuth({ id: 'u5', username: 'sarah', fullName: 'Sarah Lee' });
await setLocalPasswordForUser('u5', 'averysecurepassword');
const row = database.get<{ user_id: string; password_hash: string }>(
'SELECT user_id, password_hash FROM local_credentials WHERE user_id = ?',
['u5']
);
expect(row?.user_id).toBe('u5');
expect(row?.password_hash).toBeTruthy();
});
});

View File

@@ -1,4 +1,6 @@
import { randomUUID } from 'crypto';
import { db } from './db/index.js'; import { db } from './db/index.js';
import { setLocalCredentialPassword } from './local-credentials.js';
export interface AppUser { export interface AppUser {
id: string; id: string;
@@ -11,6 +13,7 @@ export interface AppUser {
} }
const DEFAULT_AUTH_SOURCE = 'OIDC - Synology'; const DEFAULT_AUTH_SOURCE = 'OIDC - Synology';
const LOCAL_AUTH_SOURCE = 'Local';
const normalizeOptional = (value?: string | null): string | undefined => { const normalizeOptional = (value?: string | null): string | undefined => {
const trimmed = value?.trim(); const trimmed = value?.trim();
@@ -24,12 +27,16 @@ const normalizeEmail = (value?: string | null): string | null | undefined => {
return trimmed ? trimmed : null; return trimmed ? trimmed : null;
}; };
export function listUsers(): AppUser[] { export function listUsers(): (AppUser & { has_local_credentials: boolean })[] {
return db.all<AppUser>( return db
`SELECT id, username, full_name, email, auth_source, created_at, updated_at .all<AppUser & { has_local_credentials: 0 | 1 }>(
FROM users `SELECT u.id, u.username, u.full_name, u.email, u.auth_source, u.created_at, u.updated_at,
ORDER BY username COLLATE NOCASE` CASE WHEN lc.user_id IS NOT NULL THEN 1 ELSE 0 END AS has_local_credentials
); FROM users u
LEFT JOIN local_credentials lc ON lc.user_id = u.id
ORDER BY u.username COLLATE NOCASE`
)
.map((row) => ({ ...row, has_local_credentials: row.has_local_credentials === 1 }));
} }
export function upsertUserFromAuth(input: { export function upsertUserFromAuth(input: {
@@ -112,6 +119,56 @@ export function deleteUser(id: string): void {
db.run('DELETE FROM users WHERE id = ?', [id]); db.run('DELETE FROM users WHERE id = ?', [id]);
} }
export async function createLocalUser(input: {
username: string;
fullName: string;
email?: string | null;
password: string;
}): Promise<AppUser> {
const username = input.username?.trim() ?? '';
const fullName = input.fullName?.trim() ?? '';
if (!username || !fullName) {
throw new Error('Username and full name are required');
}
const email = normalizeEmail(input.email);
const existingUsername = db.get<{ id: string }>(
'SELECT id FROM users WHERE lower(username) = lower(?)',
[username]
);
if (existingUsername) {
throw new Error('Username already exists');
}
if (email) {
const existingEmail = db.get<{ id: string }>(
'SELECT id FROM users WHERE lower(email) = lower(?)',
[email]
);
if (existingEmail) {
throw new Error('Email already exists');
}
}
const id = randomUUID();
db.run(
`INSERT INTO users (id, username, full_name, email, auth_source)
VALUES (?, ?, ?, ?, ?)`,
[id, username, fullName, email ?? null, LOCAL_AUTH_SOURCE]
);
await setLocalCredentialPassword(id, input.password);
return db.get<AppUser>('SELECT * FROM users WHERE id = ?', [id])!;
}
export async function setLocalPasswordForUser(userId: string, password: string): Promise<void> {
const id = userId?.trim() ?? '';
if (!id) {
throw new Error('User id required');
}
const existing = db.get<{ id: string }>('SELECT id FROM users WHERE id = ?', [id]);
if (!existing) {
throw new Error('User not found');
}
await setLocalCredentialPassword(id, password);
}
function syncSelfProfile(userId: string, fullName: string, email?: string | null): void { function syncSelfProfile(userId: string, fullName: string, email?: string | null): void {
const profile = db.get<{ id: string; first_name: string; last_name: string; email: string | null }>( const profile = db.get<{ id: string; first_name: string; last_name: string; email: string | null }>(
`SELECT id, first_name, last_name, email FROM people WHERE user_id = ? AND is_self = 1 LIMIT 1`, `SELECT id, first_name, last_name, email FROM people WHERE user_id = ? AND is_self = 1 LIMIT 1`,

View File

@@ -1,7 +1,7 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/admin/auth.js'; import { requireAdmin } from '$lib/server/admin/auth.js';
import { deleteUser, listUsers, updateUser } from '$lib/server/users.js'; import { createLocalUser, deleteUser, listUsers, updateUser } from '$lib/server/users.js';
export const GET: RequestHandler = async (event) => { export const GET: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user); requireAdmin((await event.locals.auth())?.user);
@@ -24,6 +24,34 @@ export const PATCH: RequestHandler = async (event) => {
return json({ ok: true }); return json({ ok: true });
}; };
export const POST: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user);
const body = await event.request.json();
const { username, full_name, email, password } = body as {
username?: string;
full_name?: string;
email?: string | null;
password?: string;
};
if (!username?.trim() || !full_name?.trim() || !password?.trim()) {
return json({ error: 'username, full_name and password required' }, { status: 400 });
}
try {
const user = await createLocalUser({
username: username.trim(),
fullName: full_name.trim(),
email,
password
});
return json(user);
} catch (error) {
return json(
{ error: error instanceof Error ? error.message : 'Failed to create user' },
{ status: 400 }
);
}
};
export const DELETE: RequestHandler = async (event) => { export const DELETE: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user); requireAdmin((await event.locals.auth())?.user);
const id = event.url.searchParams.get('id')?.trim(); const id = event.url.searchParams.get('id')?.trim();

View File

@@ -0,0 +1,22 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/admin/auth.js';
import { setLocalPasswordForUser } from '$lib/server/users.js';
export const POST: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user);
const body = await event.request.json();
const { id, password } = body as { id?: string; password?: string };
if (!id?.trim() || !password?.trim()) {
return json({ error: 'id and password required' }, { status: 400 });
}
try {
await setLocalPasswordForUser(id.trim(), password);
return json({ ok: true });
} catch (error) {
return json(
{ error: error instanceof Error ? error.message : 'Failed to set password' },
{ status: 400 }
);
}
};

View File

@@ -8,6 +8,7 @@
full_name: string; full_name: string;
email: string | null; email: string | null;
auth_source: string; auth_source: string;
has_local_credentials: boolean;
} }
let users = $state<User[]>([]); let users = $state<User[]>([]);
@@ -17,6 +18,19 @@
let editUsername = $state(''); let editUsername = $state('');
let editFullName = $state(''); let editFullName = $state('');
let editEmail = $state(''); let editEmail = $state('');
let addDrawerOpen = $state(false);
let addUsername = $state('');
let addFullName = $state('');
let addEmail = $state('');
let addPassword = $state('');
let addPasswordConfirm = $state('');
let addError = $state('');
let addSubmitting = $state(false);
let passwordDrawerUser = $state<User | null>(null);
let localPassword = $state('');
let localPasswordConfirm = $state('');
let localPasswordError = $state('');
let localPasswordSubmitting = $state(false);
async function loadUsers() { async function loadUsers() {
loading = true; loading = true;
@@ -50,6 +64,113 @@
error = ''; error = '';
} }
function openAddDrawer() {
addDrawerOpen = true;
addError = '';
}
function closeAddDrawer() {
addDrawerOpen = false;
addUsername = '';
addFullName = '';
addEmail = '';
addPassword = '';
addPasswordConfirm = '';
addError = '';
}
async function submitAddUser() {
if (!addUsername.trim() || !addFullName.trim() || !addPassword.trim()) {
addError = 'Username, full name, and password are required';
return;
}
if (addPassword.trim().length < 12) {
addError = 'Password must be at least 12 characters';
return;
}
if (addPassword.trim() !== addPasswordConfirm.trim()) {
addError = 'Passwords do not match';
return;
}
addSubmitting = true;
addError = '';
try {
const res = await fetch(`${base}/admin/api/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: addUsername.trim(),
full_name: addFullName.trim(),
email: addEmail.trim() || null,
password: addPassword
})
});
if (!res.ok) {
const data = await res.json();
addError = data.error ?? 'Failed to create user';
return;
}
closeAddDrawer();
loadUsers();
} catch (e) {
addError = e instanceof Error ? e.message : 'Failed to create user';
} finally {
addSubmitting = false;
}
}
function openPasswordDrawer(user: User) {
passwordDrawerUser = user;
localPassword = '';
localPasswordConfirm = '';
localPasswordError = '';
}
function closePasswordDrawer() {
passwordDrawerUser = null;
localPassword = '';
localPasswordConfirm = '';
localPasswordError = '';
}
async function submitLocalPassword() {
if (!passwordDrawerUser) return;
if (!localPassword.trim()) {
localPasswordError = 'Password is required';
return;
}
if (localPassword.trim().length < 12) {
localPasswordError = 'Password must be at least 12 characters';
return;
}
if (localPassword.trim() !== localPasswordConfirm.trim()) {
localPasswordError = 'Passwords do not match';
return;
}
localPasswordSubmitting = true;
localPasswordError = '';
try {
const res = await fetch(`${base}/admin/api/users/local-credentials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: passwordDrawerUser.id,
password: localPassword
})
});
if (!res.ok) {
const data = await res.json();
localPasswordError = data.error ?? 'Failed to set password';
return;
}
closePasswordDrawer();
} catch (e) {
localPasswordError = e instanceof Error ? e.message : 'Failed to set password';
} finally {
localPasswordSubmitting = false;
}
}
async function submitEdit() { async function submitEdit() {
if (!editing) return; if (!editing) return;
if (!editUsername.trim() || !editFullName.trim()) { if (!editUsername.trim() || !editFullName.trim()) {
@@ -105,10 +226,18 @@
</svelte:head> </svelte:head>
<div class="mx-auto max-w-4xl"> <div class="mx-auto max-w-4xl">
<div class="mb-6"> <div class="mb-6 flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900">Users</h1> <h1 class="text-2xl font-bold text-gray-900">Users</h1>
<p class="mt-1 text-sm text-gray-500">Manage application user accounts and access.</p> <p class="mt-1 text-sm text-gray-500">Manage application user accounts and access.</p>
</div> </div>
<button
onclick={openAddDrawer}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Add User
</button>
</div>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
{#if error && !editing} {#if error && !editing}
@@ -212,12 +341,37 @@
{user.email || '—'} {user.email || '—'}
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex flex-wrap gap-1">
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700" <span class="rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700"
>{user.auth_source}</span >{user.auth_source}</span
> >
{#if user.has_local_credentials && user.auth_source !== 'Local'}
<span class="rounded bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700"
>Local</span
>
{/if}
</div>
</td> </td>
<td class="px-4 py-3 text-right"> <td class="px-4 py-3 text-right">
<div class="inline-flex items-center gap-1"> <div class="inline-flex items-center gap-1">
<button
onclick={() => openPasswordDrawer(user)}
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-blue-500 hover:bg-blue-50 hover:text-blue-600"
aria-label="Set local password"
title="Set local password"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="h-4 w-4"
>
<path d="M21 10h-6" />
<path d="M15 10V7a3 3 0 0 0-6 0v3" />
<rect x="3" y="10" width="12" height="10" rx="2" />
</svg>
</button>
<button <button
onclick={() => startEdit(user)} onclick={() => startEdit(user)}
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700" class="inline-flex h-8 w-8 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700"
@@ -265,3 +419,179 @@
</div> </div>
</div> </div>
</div> </div>
{#if addDrawerOpen}
<div
class="fixed inset-0 z-40 bg-black/20"
role="button"
tabindex="-1"
onclick={closeAddDrawer}
onkeydown={(e: KeyboardEvent) => e.key === 'Escape' && closeAddDrawer()}
></div>
<div
class="fixed top-0 right-0 z-50 flex h-full w-full max-w-md flex-col bg-white shadow-xl"
role="dialog"
aria-modal="true"
aria-label="Add user"
onkeydown={(e: KeyboardEvent) => e.key === 'Escape' && closeAddDrawer()}
>
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<div>
<h2 class="text-base font-semibold text-gray-900">Add user</h2>
<p class="text-xs text-gray-500">Create a new local login user.</p>
</div>
<button
onclick={closeAddDrawer}
class="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="Close"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<form class="flex flex-1 flex-col gap-4 overflow-y-auto px-6 py-4" onsubmit={(e) => { e.preventDefault(); submitAddUser(); }}>
{#if addError}
<p class="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{addError}
</p>
{/if}
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500">Username <span class="text-red-500">*</span></label>
<input
type="text"
bind:value={addUsername}
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500">Full name <span class="text-red-500">*</span></label>
<input
type="text"
bind:value={addFullName}
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500">Email</label>
<input
type="email"
placeholder="name@example.com"
bind:value={addEmail}
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500">Password <span class="text-red-500">*</span></label>
<input
type="password"
bind:value={addPassword}
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
<p class="text-xs text-gray-500">Minimum 12 characters.</p>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500">Confirm password <span class="text-red-500">*</span></label>
<input
type="password"
bind:value={addPasswordConfirm}
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="mt-2 flex items-center justify-end gap-2">
<button
type="button"
onclick={closeAddDrawer}
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
disabled={addSubmitting}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
>
{addSubmitting ? 'Creating...' : 'Create user'}
</button>
</div>
</form>
</div>
{/if}
{#if passwordDrawerUser}
<div
class="fixed inset-0 z-40 bg-black/20"
role="button"
tabindex="-1"
onclick={closePasswordDrawer}
onkeydown={(e: KeyboardEvent) => e.key === 'Escape' && closePasswordDrawer()}
></div>
<div
class="fixed top-0 right-0 z-50 flex h-full w-full max-w-md flex-col bg-white shadow-xl"
role="dialog"
aria-modal="true"
aria-label="Set local password"
onkeydown={(e: KeyboardEvent) => e.key === 'Escape' && closePasswordDrawer()}
>
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<div>
<h2 class="text-base font-semibold text-gray-900">Set local password</h2>
<p class="text-xs text-gray-500">{passwordDrawerUser.username}</p>
</div>
<button
onclick={closePasswordDrawer}
class="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="Close"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<form
class="flex flex-1 flex-col gap-4 overflow-y-auto px-6 py-4"
onsubmit={(e) => { e.preventDefault(); submitLocalPassword(); }}
>
{#if localPasswordError}
<p class="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{localPasswordError}
</p>
{/if}
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500">New password <span class="text-red-500">*</span></label>
<input
type="password"
bind:value={localPassword}
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
<p class="text-xs text-gray-500">Minimum 12 characters.</p>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500">Confirm password <span class="text-red-500">*</span></label>
<input
type="password"
bind:value={localPasswordConfirm}
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="mt-2 flex items-center justify-end gap-2">
<button
type="button"
onclick={closePasswordDrawer}
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
disabled={localPasswordSubmitting}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
>
{localPasswordSubmitting ? 'Saving...' : 'Save password'}
</button>
</div>
</form>
</div>
{/if}

View File

@@ -10,10 +10,12 @@ const resolveErrorMessage = (value: string | null): string | null => {
export const load: PageServerLoad = async ({ url }) => { export const load: PageServerLoad = async ({ url }) => {
const authUrl = process.env.AUTH_URL ?? env.AUTH_URL ?? ''; const authUrl = process.env.AUTH_URL ?? env.AUTH_URL ?? '';
const localAuthEnabled = (process.env.LOCAL_AUTH_ENABLED ?? env.LOCAL_AUTH_ENABLED) === 'true'; const localAuthEnabled = (process.env.LOCAL_AUTH_ENABLED ?? env.LOCAL_AUTH_ENABLED) === 'true';
const appRoot = authUrl.replace(/\/auth$/, '');
return { return {
signinUrl: `${authUrl}/signin/synology`, signinUrl: `${authUrl}/signin/synology`,
localSigninUrl: `${authUrl}/signin/local`, localSigninUrl: `${authUrl}/callback/local`,
callbackUrl: appRoot,
localAuthEnabled, localAuthEnabled,
error: resolveErrorMessage(url.searchParams.get('error')) error: resolveErrorMessage(url.searchParams.get('error'))
}; };

View File

@@ -45,6 +45,7 @@
{#if data.localAuthEnabled} {#if data.localAuthEnabled}
<form method="POST" action={data.localSigninUrl} class="mt-6 space-y-4"> <form method="POST" action={data.localSigninUrl} class="mt-6 space-y-4">
<input type="hidden" name="csrfToken" /> <input type="hidden" name="csrfToken" />
<input type="hidden" name="callbackUrl" value={data.callbackUrl} />
<div> <div>
<label class="text-sm font-medium text-slate-700" for="identifier"> <label class="text-sm font-medium text-slate-700" for="identifier">
Username or email Username or email

View File

@@ -13,7 +13,8 @@ describe('login page load', () => {
} as Parameters<typeof load>[0]); } as Parameters<typeof load>[0]);
expect(result.signinUrl).toBe('https://example.com/auth/signin/synology'); expect(result.signinUrl).toBe('https://example.com/auth/signin/synology');
expect(result.localSigninUrl).toBe('https://example.com/auth/signin/local'); expect(result.localSigninUrl).toBe('https://example.com/auth/callback/local');
expect(result.callbackUrl).toBe('https://example.com');
expect(result.localAuthEnabled).toBe(true); expect(result.localAuthEnabled).toBe(true);
}); });

View File

@@ -5,6 +5,6 @@ import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), sveltekit()], plugins: [tailwindcss(), sveltekit()],
server: { server: {
allowedHosts: ['cloud.campbellwireless.net'] allowedHosts: ['cloud.campbellwireless.net', '127.0.0.1', 'localhost']
} }
}); });