13 Commits

Author SHA1 Message Date
9dd4025a81 trips) add low-priority e2e smoke coverage
All checks were successful
PR Checks / lint-test-and-docker-build (pull_request) Successful in 2m16s
2026-02-23 00:27:39 +00:00
f75465e7f1 e2e: add medium-priority Playwright coverage (#44)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m29s
Co-authored-by: Shaun Campbell <shaun@campbellwireless.net>
Reviewed-on: #44
Co-authored-by: AI Agent <ai-agent@campbellwireless.net>
Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
2026-02-23 00:12:24 +00:00
224ca77a9d Add high-priority Playwright trips coverage (#42)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m32s
Co-authored-by: Shaun Campbell <shaun@campbellwireless.net>
Reviewed-on: #42
Co-authored-by: AI Agent <ai-agent@campbellwireless.net>
Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
2026-02-22 22:30:44 +00:00
fe289f0895 Add Playwright e2e coverage + comment-level video upload workflow (#40)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m31s
## Summary
- add Playwright e2e auth setup + seeded test DB flow
- fix Playwright startup order so DB seed runs before web server uses SQLite
- add full trip-planning e2e scenario (future trip, travellers, destination, flight, lodging, upcoming verification)
- configure Auth.js custom sign-in page for base-path routing
- add repo guidance in AGENTS.md / CLAUDE.md requiring e2e for user-facing changes
- document and validate comment-level Gitea video attachment workflow (create comment, upload to comment assets endpoint)

## Validation
- bunx playwright test e2e/auth.test.ts --project=chromium\n- bunx playwright test e2e/trip-planning.test.ts --project=chromium
- PW_VIDEO_MODE=on PW_TRACE_MODE=off bunx playwright test e2e/trip-planning.test.ts --project=chromium

## Issue
- relates to #38

Co-authored-by: AI Agent <ai-agent@campbellwireless.net>
Reviewed-on: #40
Co-authored-by: Shaun Campbell <shaun@campbellwireless.net>
Co-committed-by: Shaun Campbell <shaun@campbellwireless.net>
2026-02-22 21:20:19 +00:00
e11fcb56a2 admin) allow local login user management (#39)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m28s
Co-authored-by: Shaun Campbell <shaun@campbellwireless.net>
Reviewed-on: #39
Co-authored-by: AI Agent <ai-agent@campbellwireless.net>
Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
2026-02-22 20:07:46 +00:00
fc8e80186d Add local authentication option (#37)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m20s
Co-authored-by: Shaun Campbell <shaun@campbellwireless.net>
Reviewed-on: #37
Co-authored-by: AI Agent <ai-agent@campbellwireless.net>
Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
2026-02-22 05:17:45 +00:00
42fe36ca86 admin) User management admin page (#35)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m20s
Reviewed-on: #35
Reviewed-by: shaun <shaun@campbellwireless.net>
Co-authored-by: AI Agent <ai-agent@campbellwireless.net>
Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
2026-02-22 04:26:16 +00:00
4b3af5e947 trips) portainer API deploy + preflight checks (#5)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m25s
- Switch CI deploy step from Portainer webhook to Portainer API (CE-compatible)
- Add manual workflow trigger (`workflow_dispatch`)
- Add preflight checks for Portainer auth, endpoint ID, and stack ID before redeploy
- Update README with required secrets and deploy flow

Reviewed-on: #5
Co-authored-by: Shaun Campbell <shaun@campbellwireless.net>
Co-committed-by: Shaun Campbell <shaun@campbellwireless.net>
2026-02-21 06:20:51 +00:00
5101129f23 ci) updating docker registry to use registry.campbellwireless.net
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m2s
2026-02-21 00:18:56 -05:00
bfe2e91dee ci) Updating docker registry host (#4)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 1m50s
Reviewed-on: #4
Co-authored-by: Shaun Campbell <shaun@campbellwireless.net>
Co-committed-by: Shaun Campbell <shaun@campbellwireless.net>
2026-02-21 05:12:16 +00:00
cc78d99326 ci) Updating docker container URI (#3)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 1m47s
Reviewed-on: #3
Co-authored-by: Shaun Campbell <shaun@campbellwireless.net>
Co-committed-by: Shaun Campbell <shaun@campbellwireless.net>
2026-02-21 04:50:46 +00:00
d9aab48906 ci) Updating merge image task (#2)
Some checks failed
Build and Push Image / docker-build-and-push (push) Failing after 1m48s
Reviewed-on: CampbellWireless/trips#2
Co-authored-by: Shaun Campbell <shaun@campbellwireless.net>
Co-committed-by: Shaun Campbell <shaun@campbellwireless.net>
2026-02-21 04:40:42 +00:00
b453821d3f ci) add gitea actions and refresh readme (#1)
Some checks failed
Build and Push Image / docker-build-and-push (push) Failing after 2m13s
Summary:\n- replace scaffold README with project-specific setup, testing, Docker, and CI docs\n- add PR workflow to run lint, unit tests, app build, and Docker build\n- add main-branch workflow to build and push Docker images to Gitea registry\n\nNotes:\n- publish workflow expects REGISTRY_USERNAME and REGISTRY_PASSWORD repository secrets\n- image tags pushed: latest and short commit SHA\n\nTesting:\n- not run locally (workflows execute in Gitea Actions)
Reviewed-on: CampbellWireless/trips#1
Co-authored-by: Shaun Campbell <shaun@campbellwireless.net>
Co-committed-by: Shaun Campbell <shaun@campbellwireless.net>
2026-02-21 04:33:47 +00:00
63 changed files with 3177 additions and 193 deletions

View File

@@ -10,6 +10,15 @@ AUTH_URL=https://cloud.campbellwireless.net/trips/auth
# Auth.js secret — generate with: openssl rand -base64 32
AUTH_SECRET=
# Local auth
LOCAL_AUTH_ENABLED=false
LOCAL_AUTH_ARGON2_MEMORY_KB=65536
LOCAL_AUTH_ARGON2_TIME_COST=3
LOCAL_AUTH_ARGON2_PARALLELISM=1
LOCAL_AUTH_MAX_ATTEMPTS=5
LOCAL_AUTH_WINDOW_SECONDS=900
LOCAL_AUTH_LOCKOUT_SECONDS=900
# Admin — comma-separated user IDs (from auth provider) that can access /admin
ADMIN_USER_IDS=

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

@@ -4,9 +4,10 @@ on:
push:
branches:
- main
workflow_dispatch:
env:
REGISTRY_HOST: git.campbellwireless.net
REGISTRY_HOST: registry.campbellwireless.net
IMAGE_NAME: ${{ github.repository }}
jobs:
@@ -15,7 +16,16 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
env:
REPO_URL: https://cloud.campbellwireless.net/git/${{ github.repository }}.git
run: |
set -eux
git init .
git remote add origin "$REPO_URL"
auth="$(printf '%s' '${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}' | base64 | tr -d '\n')"
git config --local http.https://cloud.campbellwireless.net/.extraheader "AUTHORIZATION: basic $auth"
git fetch --prune --no-recurse-submodules origin +refs/heads/*:refs/remotes/origin/* +refs/tags/*:refs/tags/*
git checkout --detach "${{ github.sha }}"
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -41,3 +51,107 @@ jobs:
tags: |
${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}:${{ steps.tags.outputs.sha_short }}
- name: Preflight Portainer deploy config
env:
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }}
PORTAINER_STACK_ID: ${{ secrets.PORTAINER_STACK_ID }}
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }}
PORTAINER_INSECURE_TLS: ${{ secrets.PORTAINER_INSECURE_TLS }}
run: |
set -eu
for required in PORTAINER_URL PORTAINER_API_KEY PORTAINER_STACK_ID PORTAINER_ENDPOINT_ID; do
if [ -z "$(eval "printf '%s' \"\${$required:-}\"")" ]; then
echo "Missing required secret: $required" >&2
exit 1
fi
done
CURL_ARGS=(--fail --show-error --silent --retry 3 --retry-all-errors)
if [ "${PORTAINER_INSECURE_TLS:-false}" = "true" ]; then
CURL_ARGS+=(--insecure)
fi
API_BASE="${PORTAINER_URL%/}/api"
ENDPOINT_JSON="$(
curl "${CURL_ARGS[@]}" \
--header "X-API-Key: ${PORTAINER_API_KEY}" \
"${API_BASE}/endpoints/${PORTAINER_ENDPOINT_ID}"
)"
echo "${ENDPOINT_JSON}" | jq -e --arg id "${PORTAINER_ENDPOINT_ID}" \
'((.Id // .id) | tostring) == $id' >/dev/null
STACK_JSON="$(
curl "${CURL_ARGS[@]}" \
--header "X-API-Key: ${PORTAINER_API_KEY}" \
"${API_BASE}/stacks/${PORTAINER_STACK_ID}?endpointId=${PORTAINER_ENDPOINT_ID}"
)"
echo "${STACK_JSON}" | jq -e --arg id "${PORTAINER_STACK_ID}" \
'((.Id // .id) | tostring) == $id' >/dev/null
echo "Portainer preflight checks passed."
- name: Trigger Portainer stack redeploy
env:
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }}
PORTAINER_STACK_ID: ${{ secrets.PORTAINER_STACK_ID }}
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }}
PORTAINER_INSECURE_TLS: ${{ secrets.PORTAINER_INSECURE_TLS }}
run: |
set -eu
for required in PORTAINER_URL PORTAINER_API_KEY PORTAINER_STACK_ID PORTAINER_ENDPOINT_ID; do
if [ -z "$(eval "printf '%s' \"\${$required:-}\"")" ]; then
echo "Missing required secret: $required" >&2
exit 1
fi
done
CURL_ARGS=(--fail --show-error --silent --retry 3 --retry-all-errors)
if [ "${PORTAINER_INSECURE_TLS:-false}" = "true" ]; then
CURL_ARGS+=(--insecure)
fi
STACK_BASE="${PORTAINER_URL%/}/api/stacks/${PORTAINER_STACK_ID}"
QUERY="endpointId=${PORTAINER_ENDPOINT_ID}"
# Git-based stacks can be redeployed directly.
if curl "${CURL_ARGS[@]}" \
--header "X-API-Key: ${PORTAINER_API_KEY}" \
--request POST \
"${STACK_BASE}/git/redeploy?${QUERY}" >/dev/null; then
echo "Portainer deploy: git stack redeploy triggered."
exit 0
fi
# Non-git stacks: fetch current stack file and redeploy with pullImage=true.
STACK_FILE_CONTENT="$(
curl "${CURL_ARGS[@]}" \
--header "X-API-Key: ${PORTAINER_API_KEY}" \
"${STACK_BASE}/file" \
| jq -r '.StackFileContent'
)"
PAYLOAD_LOWER="$(jq -cn --arg stackFileContent "${STACK_FILE_CONTENT}" \
'{stackFileContent: $stackFileContent, prune: false, pullImage: true}')"
if curl "${CURL_ARGS[@]}" \
--header "X-API-Key: ${PORTAINER_API_KEY}" \
--header "Content-Type: application/json" \
--request PUT \
--data "${PAYLOAD_LOWER}" \
"${STACK_BASE}?${QUERY}" >/dev/null; then
echo "Portainer deploy: stack updated with lower-camel payload."
exit 0
fi
PAYLOAD_UPPER="$(jq -cn --arg StackFileContent "${STACK_FILE_CONTENT}" \
'{StackFileContent: $StackFileContent, Prune: false, PullImage: true}')"
curl "${CURL_ARGS[@]}" \
--header "X-API-Key: ${PORTAINER_API_KEY}" \
--header "Content-Type: application/json" \
--request PUT \
--data "${PAYLOAD_UPPER}" \
"${STACK_BASE}?${QUERY}" >/dev/null
echo "Portainer deploy: stack updated with upper-camel payload."

View File

@@ -9,21 +9,33 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
env:
REPO_URL: https://cloud.campbellwireless.net/git/${{ github.repository }}.git
run: |
set -eux
git init .
git remote add origin "$REPO_URL"
auth="$(printf '%s' '${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}' | base64 | tr -d '\n')"
git config --local http.https://cloud.campbellwireless.net/.extraheader "AUTHORIZATION: basic $auth"
git fetch --prune --no-recurse-submodules origin +refs/heads/*:refs/remotes/origin/* +refs/tags/*:refs/tags/*
git checkout --detach "${{ github.sha }}"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.2.22'
bun-version: '1.3.3'
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Generate SvelteKit files
run: bun run prepare
- name: Lint
run: bun run lint
- name: Unit tests
run: bun run test
run: bunx --bun svelte-kit sync && bunx --bun vitest run --maxWorkers=1
- name: Build app
run: bun run build

View File

@@ -31,6 +31,14 @@
- Framework: Vitest with `node` environment.
- Test file pattern: `src/**/*.test.ts`.
- 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 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)
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
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

@@ -7,24 +7,22 @@ WORKDIR /app
COPY package.json bun.lock ./
# Install all deps (including devDependencies needed for the build).
# better-sqlite3 is a native module — it must be compiled here on Linux,
# not copied from a macOS node_modules.
RUN bun install --frozen-lockfile
# Copy source and build
COPY . .
RUN bun run build
# Prune to production-only deps, recompiling native modules for Linux
# Prune to production-only deps
RUN bun install --frozen-lockfile --production
# ── Runtime stage ───────────────────────────────────────────────────────────────
FROM node:22-alpine AS runtime
FROM oven/bun:1 AS runtime
WORKDIR /app
# Create a non-root user to run the app
RUN addgroup -S trips && adduser -S trips -G trips
RUN groupadd --system trips && useradd --system --gid trips trips
# Copy the built app and production node_modules from the builder
COPY --from=builder /app/build ./build
@@ -42,4 +40,4 @@ ENV PORT=3000
EXPOSE 3000
CMD ["node", "build"]
CMD ["bun", "build/index.js"]

View File

@@ -5,10 +5,10 @@ A SvelteKit travel planning app for managing trips, travelers, transportation, l
## Tech Stack
- SvelteKit + TypeScript
- SQLite (`better-sqlite3`)
- SQLite (`bun:sqlite`)
- Vitest for unit tests
- ESLint + Prettier for code quality
- Bun for local/deploy build workflows
- Biome + Prettier for code quality
- Bun for local/deploy build and runtime workflows
- Docker multi-stage build for production image
## Features
@@ -29,7 +29,6 @@ A SvelteKit travel planning app for managing trips, travelers, transportation, l
### Requirements
- Bun 1.x
- Node.js 22+ (runtime target)
### Setup
@@ -44,6 +43,19 @@ bun install
bun run dev
```
## Local authentication
Local auth is optional and off by default. Enable it with the env vars in `.env.example` and ensure users have a matching row in both `users` and `local_credentials`.
To seed a local password hash, use Argon2id with the configured parameters and insert it into `local_credentials`:
```sql
INSERT INTO local_credentials (user_id, password_hash)
VALUES ('<user-id>', '<argon2id hash>');
```
Passwords must be at least 12 characters. Avoid storing plaintext passwords anywhere.
## Quality and Tests
```sh
@@ -80,7 +92,7 @@ The container stores SQLite data at `/data/trips.db` (mounted as the `trips-data
Workflows live in `.gitea/workflows`:
- `pr-checks.yml`: runs lint, tests, and Docker build on pull requests.
- `main-image.yml`: builds and pushes a Docker image on push to `main`.
- `main-image.yml`: builds and pushes a Docker image on push to `main`, then calls the Portainer API to redeploy. It can also be run manually from the Actions UI.
### Registry secrets for image publish
@@ -88,10 +100,62 @@ Configure these repository secrets in Gitea:
- `REGISTRY_USERNAME`
- `REGISTRY_PASSWORD`
- `PORTAINER_URL` (for example, `https://portainer.example.com`)
- `PORTAINER_API_KEY` (Portainer API key for a user with access to the stack)
- `PORTAINER_STACK_ID` (numeric stack ID in Portainer)
- `PORTAINER_ENDPOINT_ID` (numeric environment/endpoint ID in Portainer)
- `PORTAINER_INSECURE_TLS` (optional: set to `true` only if Portainer uses self-signed TLS)
By default, the publish workflow pushes to:
- `git.campbellwireless.net/<owner>/<repo>:latest`
- `git.campbellwireless.net/<owner>/<repo>:<short-sha>`
- `registry.campbellwireless.net/<owner>/<repo>:latest`
- `registry.campbellwireless.net/<owner>/<repo>:<short-sha>`
If your registry host differs, edit `REGISTRY_HOST` in `.gitea/workflows/main-image.yml`.
## Deploy with Portainer API
The `main-image.yml` workflow now calls the Portainer API after pushing `latest`.
In Portainer, create/update your stack to use a published image (not `build`), for example:
```yaml
services:
trips:
image: registry.campbellwireless.net/<owner>/<repo>:latest
container_name: trips
restart: unless-stopped
ports:
- '3000:3000'
volumes:
- trips-data:/data
env_file:
- .env
environment:
NODE_ENV: production
DATABASE_URL: file:/data/trips.db
PORT: '3000'
volumes:
trips-data:
```
Then in Portainer:
1. Create an API key from your user profile (`My account` -> `API keys`).
2. Open the stack details page and note the stack ID.
3. Open `Environments` and note the endpoint/environment ID where the stack runs.
4. Save these values in your Gitea repository secrets:
- `PORTAINER_URL`
- `PORTAINER_API_KEY`
- `PORTAINER_STACK_ID`
- `PORTAINER_ENDPOINT_ID`
- optional `PORTAINER_INSECURE_TLS=true`
Flow on each push to `main` (or manual run of `main-image.yml`):
1. Build image.
2. Push `:latest` and `:<short-sha>`.
3. Run Portainer preflight checks (auth + stack/endpoint IDs).
4. Call Portainer API.
5. Portainer redeploys the stack and pulls the updated image.

14
biome.json Normal file
View File

@@ -0,0 +1,14 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
"files": {
"includes": [
"src/**/*.ts",
"src/**/*.js",
"src/**/*.mjs",
"src/**/*.cjs"
]
},
"linter": {
"enabled": true
}
}

111
bun.lock
View File

@@ -6,16 +6,19 @@
"name": "trips",
"dependencies": {
"@auth/sveltekit": "^1.11.1",
"better-sqlite3": "^12.6.2",
"argon2": "^0.41.1",
},
"devDependencies": {
"@biomejs/biome": "^2.4.4",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.58.2",
"@sveltejs/adapter-node": "^5.5.3",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.0",
"@types/better-sqlite3": "^7.6.13",
"@vitest/coverage-v8": "^4.0.18",
"bun-types": "^1.3.9",
"dotenv": "^17.3.1",
"eslint": "^10.0.0",
"eslint-plugin-svelte": "^3.15.0",
"globals": "^17.3.0",
@@ -47,6 +50,24 @@
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
"@biomejs/biome": ["@biomejs/biome@2.4.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.4", "@biomejs/cli-darwin-x64": "2.4.4", "@biomejs/cli-linux-arm64": "2.4.4", "@biomejs/cli-linux-arm64-musl": "2.4.4", "@biomejs/cli-linux-x64": "2.4.4", "@biomejs/cli-linux-x64-musl": "2.4.4", "@biomejs/cli-win32-arm64": "2.4.4", "@biomejs/cli-win32-x64": "2.4.4" }, "bin": { "biome": "bin/biome" } }, "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
@@ -135,6 +156,10 @@
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
"@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=="],
"@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=="],
@@ -237,8 +262,6 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.0", "", { "dependencies": { "@tailwindcss/node": "4.2.0", "@tailwindcss/oxide": "4.2.0", "tailwindcss": "4.2.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-da9mFCaHpoOgtQiWtDGIikTrSpUFBtIZCG3jy/u2BGV+l/X1/pbxzmIUxNt6JWm19N3WtGi4KlJdSH/Si83WOA=="],
"@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
@@ -299,6 +322,8 @@
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"argon2": ["argon2@0.41.1", "", { "dependencies": { "@phc/format": "^1.0.0", "node-addon-api": "^8.1.0", "node-gyp-build": "^4.8.1" } }, "sha512-dqCW8kJXke8Ik+McUcMDltrbuAWETPyU6iq+4AhxqKphWi7pChB/Zgd/Tp/o8xRLbg8ksMj46F/vph9wnxpTzQ=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
@@ -309,24 +334,14 @@
"balanced-match": ["balanced-match@4.0.3", "", {}, "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="],
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="],
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
@@ -339,10 +354,6 @@
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
@@ -351,7 +362,7 @@
"devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"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=="],
@@ -385,8 +396,6 @@
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
@@ -399,22 +408,16 @@
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@17.3.0", "", {}, "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw=="],
@@ -427,16 +430,10 @@
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
@@ -511,14 +508,8 @@
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
"minimatch": ["minimatch@10.2.2", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
@@ -527,18 +518,16 @@
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="],
"node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="],
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
"oauth4webapi": ["oauth4webapi@3.8.5", "", {}, "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
@@ -557,6 +546,10 @@
"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-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=="],
@@ -571,8 +564,6 @@
"preact-render-to-string": ["preact-render-to-string@6.5.11", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="],
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
@@ -581,14 +572,8 @@
"prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.7.2", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte"] }, "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA=="],
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
@@ -597,8 +582,6 @@
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="],
@@ -609,10 +592,6 @@
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@@ -621,10 +600,6 @@
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
@@ -639,10 +614,6 @@
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
@@ -655,8 +626,6 @@
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
@@ -681,8 +650,6 @@
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
@@ -717,6 +684,8 @@
"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-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],

View File

@@ -0,0 +1,73 @@
import { test, expect, type Locator, type Page } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js';
const LOGIN_URL = '/trips/login';
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('**/trips/dashboard', { timeout: 15_000 });
}
function rowForUser(page: Page, username: string): Locator {
return page.getByRole('cell', { name: username }).locator('..');
}
test.beforeEach(async ({ context }) => {
await context.clearCookies();
});
test('admin sees auth source and can set local password', async ({ page }) => {
const suffix = Date.now();
const username = `e2e_auth_source_${suffix}`;
const fullName = `E2E Auth Source ${suffix}`;
const email = `e2e_auth_source_${suffix}@test.local`;
const password = `InitPass-${suffix}-123`;
await loginAsLocalUser(page, TEST_USERS.admin.username, TEST_USERS.admin.password);
await page.goto(ADMIN_USERS_URL);
await expect(page.getByRole('heading', { name: 'Users' })).toBeVisible();
const regularRow = rowForUser(page, TEST_USERS.regular.username);
await expect(regularRow.getByText('Local', { exact: true })).toBeVisible();
const adminRow = rowForUser(page, TEST_USERS.admin.username);
await expect(adminRow.getByText('Local', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Add User' }).click();
const addDialog = page.getByRole('dialog', { name: 'Add user' });
await expect(addDialog).toBeVisible();
await addDialog.locator('input[type="text"]').nth(0).fill(username);
await addDialog.locator('input[type="text"]').nth(1).fill(fullName);
await addDialog.locator('input[type="email"]').fill(email);
await addDialog.locator('input[type="password"]').nth(0).fill(password);
await addDialog.locator('input[type="password"]').nth(1).fill(password);
await addDialog.getByRole('button', { name: 'Create user' }).click();
const disposableUserRow = rowForUser(page, username);
await expect(disposableUserRow.getByText('Local', { exact: true })).toBeVisible();
await disposableUserRow.getByRole('button', { name: 'Set local password' }).click();
const dialog = page.getByRole('dialog', { name: 'Set local password' });
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Save password' }).click();
await expect(dialog.getByText('Password is required')).toBeVisible();
await dialog.locator('input[type="password"]').nth(0).fill('short');
await dialog.locator('input[type="password"]').nth(1).fill('short');
await dialog.getByRole('button', { name: 'Save password' }).click();
await expect(dialog.getByText('Password must be at least 12 characters')).toBeVisible();
await dialog.locator('input[type="password"]').nth(0).fill('long-enough-password');
await dialog.locator('input[type="password"]').nth(1).fill('long-enough-password-mismatch');
await dialog.getByRole('button', { name: 'Save password' }).click();
await expect(dialog.getByText('Passwords do not match')).toBeVisible();
await dialog.locator('input[type="password"]').nth(0).fill('local-password-123');
await dialog.locator('input[type="password"]').nth(1).fill('local-password-123');
await dialog.getByRole('button', { name: 'Save password' }).click();
await expect(dialog).toBeHidden();
});

View File

@@ -0,0 +1,49 @@
import { test, expect, type Page } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js';
const LOGIN_URL = '/trips/login';
const ADMIN_USERS_URL = '/trips/admin/users';
const DASHBOARD_URL = '/trips/dashboard';
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('admin can create a local user who can sign in', async ({ page }) => {
const suffix = Date.now();
const username = `e2e_new_user_${suffix}`;
const fullName = `E2E New User ${suffix}`;
const email = `e2e_new_user_${suffix}@test.local`;
const password = `TempPass-${suffix}-123`;
await loginAsLocalUser(page, TEST_USERS.admin.username, TEST_USERS.admin.password);
await page.goto(ADMIN_USERS_URL);
await expect(page.getByRole('heading', { name: 'Users' })).toBeVisible();
await page.getByRole('button', { name: 'Add User' }).click();
const addDialog = page.getByRole('dialog', { name: 'Add user' });
await expect(addDialog).toBeVisible();
await addDialog.locator('input[type="text"]').nth(0).fill(username);
await addDialog.locator('input[type="text"]').nth(1).fill(fullName);
await addDialog.locator('input[type="email"]').fill(email);
await addDialog.locator('input[type="password"]').nth(0).fill(password);
await addDialog.locator('input[type="password"]').nth(1).fill(password);
await addDialog.getByRole('button', { name: 'Create user' }).click();
await expect(page.getByRole('cell', { name: username, exact: true })).toBeVisible();
await page.context().clearCookies();
await loginAsLocalUser(page, username, password);
await expect(page).toHaveURL(/\/trips\/dashboard/);
await page.goto(ADMIN_USERS_URL);
await expect(page).toHaveURL(/\/trips\/dashboard/);
});

25
e2e/auth-callback.test.ts Normal file
View File

@@ -0,0 +1,25 @@
import { test, expect, type Page } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js';
const LOGIN_URL = '/trips/login';
const UPCOMING_URL = '/trips/trips/upcoming';
async function submitLocalLogin(page: Page, username: string, password: string): Promise<void> {
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")');
}
test.beforeEach(async ({ context }) => {
await context.clearCookies();
});
test('redirects back to protected route after local login', async ({ page }) => {
await page.goto(UPCOMING_URL);
await expect(page).toHaveURL(/\/trips\/login/);
await submitLocalLogin(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await page.waitForURL(/\/trips\/(trips\/upcoming|dashboard)/, { timeout: 15_000 });
await expect(page).not.toHaveURL(/\/trips\/auth/);
await expect(page).not.toHaveURL(/\/trips\/login/);
});

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');
});
});

View File

@@ -0,0 +1,44 @@
import { test, expect } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js';
import { loginAsLocalUser } from './helpers/auth.js';
import { addActivity, addPackingList, createTrip, uniqueSuffix } from './helpers/trip.js';
test.beforeEach(async ({ context }) => {
await context.clearCookies();
});
test('checklists and experiences persist after reload', async ({ page }) => {
const suffix = uniqueSuffix();
const tripName = `E2E Checklist ${suffix}`;
const listName = `Packing ${suffix}`;
const itemOne = `Passport ${suffix}`;
const itemTwo = `Sunscreen ${suffix}`;
const activityName = `Museum Visit ${suffix}`;
await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await createTrip(page, { name: tripName, description: 'Checklist persistence coverage' });
await addPackingList(page, { name: listName, items: [itemOne, itemTwo] });
await addActivity(page, { name: activityName });
const toggleRequest = page.waitForResponse(
(response) =>
response.request().method() === 'POST' && response.url().includes('/toggleChecklistItem')
);
await page.getByRole('checkbox', { name: itemOne }).check();
await toggleRequest;
await expect(page.getByRole('checkbox', { name: itemOne })).toBeChecked();
await page.reload();
await expect(page.getByText(activityName, { exact: true })).toBeVisible();
await expect(page.getByRole('checkbox', { name: itemOne })).toBeChecked();
await expect(page.getByRole('checkbox', { name: itemTwo })).not.toBeChecked();
const listCard = page
.getByText(listName, { exact: true })
.locator('xpath=ancestor::div[contains(@class,"rounded-xl")]');
const items = listCard.locator('label');
await expect(items.nth(0)).toContainText(itemOne);
await expect(items.nth(1)).toContainText(itemTwo);
});

View File

@@ -0,0 +1,9 @@
import { test, expect } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js';
import { loginAsLocalUser } from './helpers/auth.js';
test('evidence workflow @evidence login to dashboard', async ({ page }) => {
await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await expect(page).toHaveURL(/\/trips\/dashboard/);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});

27
e2e/helpers/auth.ts Normal file
View File

@@ -0,0 +1,27 @@
import { 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 PROFILE_URL = '/trips/profile';
export 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 });
}
export async function ensureSelfProfile(page: Page, email = TEST_USERS.regular.email): Promise<void> {
await page.goto(PROFILE_URL);
await page.fill('#first_name', 'E2E');
await page.fill('#last_name', 'User');
await page.fill('#email', email);
await page.getByRole('button', { name: 'Save profile' }).click();
await expect(page.getByRole('heading', { name: 'My Profile' })).toBeVisible();
}

159
e2e/helpers/trip.ts Normal file
View File

@@ -0,0 +1,159 @@
import { expect, type Page } from '@playwright/test';
const NEW_TRIP_URL = '/trips/trips/new';
function escapeRegex(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function uniqueSuffix(): string {
return `${Date.now()}-${Math.floor(Math.random() * 1000)}`;
}
export async function createTrip(
page: Page,
values: { name: string; startDate?: string; description?: string }
): Promise<{ tripUrl: string; tripId: string }> {
await page.goto(NEW_TRIP_URL);
await expect(page.getByRole('heading', { name: 'Plan New Trip' })).toBeVisible();
if (values.startDate) {
await page.getByLabel('Start date').fill(values.startDate);
} else {
await page.getByRole('checkbox', { name: "I don't know yet" }).first().check();
}
if (values.description) {
await page.getByLabel('Description').fill(values.description);
}
await page.locator('input[name="name"]').fill(values.name);
await page.getByRole('button', { name: 'Save' }).click();
await expect(page).toHaveURL(/\/trips\/trips\/(?!new$)[^/?#]+$/, { timeout: 15_000 });
const tripUrl = page.url();
const tripId = tripUrl.split('/').pop() ?? '';
return { tripUrl, tripId };
}
export async function openAddToTripMenuItem(page: Page, label: string): Promise<void> {
const addToTripButton = page.getByRole('button', { name: /^Add to trip$/i });
if (await addToTripButton.isVisible().catch(() => false)) {
await addToTripButton.click();
await page.getByRole('button', { name: label, exact: true }).click();
return;
}
// In the welcome state, button names include label + subtitle.
await page
.getByRole('button', { name: new RegExp(`^${escapeRegex(label)}\\b`, 'i') })
.first()
.click();
}
export async function openAddTraveller(page: Page): Promise<void> {
const existingDialog = page.getByRole('dialog', { name: 'Add traveller' });
if (await existingDialog.isVisible().catch(() => false)) return;
const addToTripButton = page.getByRole('button', { name: /^Add to trip$/i });
if (await addToTripButton.isVisible().catch(() => false)) {
await addToTripButton.click();
await page.getByRole('button', { name: 'Travellers', exact: true }).click();
return;
}
await page.getByRole('button', { name: /^Who's travelling\?/ }).click();
}
export async function addTraveller(
page: Page,
values: { firstName: string; lastName: string; email?: string }
): Promise<void> {
await openAddTraveller(page);
const dialog = page.getByRole('dialog', { name: 'Add traveller' });
await expect(dialog).toBeVisible();
const addNewButton = dialog.getByRole('button', { name: 'Add someone new' });
if (await addNewButton.isVisible().catch(() => false)) {
await addNewButton.click();
}
await dialog.getByLabel('First name', { exact: false }).fill(values.firstName);
await dialog.getByLabel('Last name', { exact: false }).fill(values.lastName);
if (values.email) {
await dialog.getByLabel(/Email/).fill(values.email);
}
await dialog.getByRole('button', { name: 'Add traveller' }).click();
await expect(dialog).toBeHidden();
}
export async function addFlight(
page: Page,
values: {
departureDate: string;
airlineCode: string;
flightNumber: string;
departureAirport: string;
arrivalAirport: string;
}
): Promise<void> {
await openAddToTripMenuItem(page, 'Transportation');
const dialog = page.getByRole('dialog', { name: 'Add transportation' });
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /Flight/ }).click();
const form = dialog.locator('form');
await expect(form.locator('input[name="segments[0][departure_date]"]')).toBeVisible();
await form.locator('input[name="segments[0][departure_date]"]').fill(values.departureDate);
await form.getByPlaceholder('Search airline or enter code').fill(values.airlineCode);
await form.locator('input[name="segments[0][flight_number]"]').fill(values.flightNumber);
await form.getByPlaceholder('Code or search').nth(0).fill(values.departureAirport);
await form.getByPlaceholder('Code or search').nth(1).fill(values.arrivalAirport);
await form.getByRole('button', { name: 'Add transportation' }).click();
await expect(dialog).toBeHidden();
}
export async function addLodging(
page: Page,
values: { name: string; guestNames?: string[] }
): Promise<void> {
await openAddToTripMenuItem(page, 'Lodgings');
const dialog = page.getByRole('dialog', { name: 'Add lodging' });
await expect(dialog).toBeVisible();
await dialog.getByLabel('Name', { exact: false }).fill(values.name);
for (const guestName of values.guestNames ?? []) {
await dialog.getByRole('checkbox', { name: guestName }).check();
}
await dialog.getByRole('button', { name: 'Add lodging' }).click();
await expect(dialog).toBeHidden();
}
export async function addPackingList(
page: Page,
values: { name: string; items: string[] }
): Promise<void> {
await openAddToTripMenuItem(page, 'Packing List');
const dialog = page
.locator('[role="dialog"]')
.filter({ has: page.getByRole('heading', { name: /Packing list/i }) })
.first();
await expect(dialog).toBeVisible();
await dialog.getByLabel('Name', { exact: false }).fill(values.name);
const itemInputs = dialog.getByPlaceholder('Item');
await itemInputs.first().fill(values.items[0]);
for (const item of values.items.slice(1)) {
await dialog.getByRole('button', { name: 'Add item' }).click();
await itemInputs.last().fill(item);
}
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
await expect(dialog).toBeHidden();
}
export async function addActivity(page: Page, values: { name: string }): Promise<void> {
await openAddToTripMenuItem(page, 'Attractions & Activities');
const dialog = page
.locator('[role="dialog"]')
.filter({ has: page.getByRole('heading', { name: /Attraction & activity/i }) })
.first();
await expect(dialog).toBeVisible();
await dialog.getByLabel('Name', { exact: false }).fill(values.name);
await dialog.getByRole('button', { name: 'Add' }).click();
await expect(dialog).toBeHidden();
}

View File

@@ -0,0 +1,79 @@
import { test, expect } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js';
import { ensureSelfProfile, loginAsLocalUser } from './helpers/auth.js';
import {
addTraveller,
createTrip,
openAddToTripMenuItem,
openAddTraveller,
uniqueSuffix
} from './helpers/trip.js';
test.beforeEach(async ({ context }) => {
await context.clearCookies();
});
test('lodging guest selection and access boundaries are enforced', async ({ page }) => {
const suffix = uniqueSuffix();
const tripName = `E2E Lodging ${suffix}`;
const guestFirst = 'Jordan';
const guestLast = 'Guest';
const guestEmail = `jordan.${suffix}@test.local`;
const lodgingName = `E2E Lodge ${suffix}`;
await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await ensureSelfProfile(page);
const { tripUrl } = await createTrip(page, {
name: tripName,
description: 'Lodging guest coverage'
});
const selfName = 'E2E User';
await openAddTraveller(page);
const travellerDialog = page.getByRole('dialog', { name: 'Add traveller' });
await expect(travellerDialog).toBeVisible();
const selfButton = travellerDialog.getByRole('button', { name: /You/ }).first();
if (await selfButton.isVisible().catch(() => false)) {
await selfButton.click();
await expect(travellerDialog).toBeHidden();
} else {
const selfNameButton = travellerDialog.getByRole('button', { name: selfName }).first();
if (await selfNameButton.isVisible().catch(() => false)) {
await selfNameButton.click();
await expect(travellerDialog).toBeHidden();
} else {
await travellerDialog.getByRole('button', { name: 'Close' }).click();
await expect(travellerDialog).toBeHidden();
}
}
await addTraveller(page, {
firstName: guestFirst,
lastName: guestLast,
email: guestEmail
});
await openAddToTripMenuItem(page, 'Lodgings');
const lodgingDialog = page.getByRole('dialog', { name: 'Add lodging' });
await expect(lodgingDialog).toBeVisible();
const guestName = `${guestFirst} ${guestLast}`;
await expect(lodgingDialog.getByRole('checkbox', { name: guestName })).toBeVisible();
await lodgingDialog.getByLabel('Name', { exact: false }).fill(lodgingName);
await lodgingDialog.getByText(guestName, { exact: true }).click();
await lodgingDialog.getByRole('button', { name: 'Add lodging' }).click();
await expect(lodgingDialog).toBeHidden();
await expect(page.getByText(lodgingName, { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Edit lodging' }).click();
const editDialog = page.getByRole('dialog', { name: 'Edit lodging' });
await expect(editDialog).toBeVisible();
await expect(editDialog.getByRole('checkbox', { name: guestName })).toBeChecked();
await editDialog.getByRole('button', { name: 'Close' }).click();
await page.context().clearCookies();
await loginAsLocalUser(page, TEST_USERS.admin.username, TEST_USERS.admin.password);
await page.goto(tripUrl);
await expect(page.getByText(/Trip not found|Not found/i)).toBeVisible();
});

View File

@@ -0,0 +1,60 @@
import { test, expect } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js';
import { loginAsLocalUser } from './helpers/auth.js';
import { createTrip, openAddToTripMenuItem, uniqueSuffix } from './helpers/trip.js';
test.beforeEach(async ({ context }) => {
await context.clearCookies();
});
test('admin can import a package tour and attach it to a trip', async ({ page }) => {
if (!process.env.GADVENTURES_API_KEY) {
test.skip(true, 'G Adventures API key not set for deterministic import');
}
const suffix = uniqueSuffix();
const operatorName = 'G Adventures';
await loginAsLocalUser(page, TEST_USERS.admin.username, TEST_USERS.admin.password);
await page.goto('/trips/admin/tour-operators');
await expect(page.getByRole('heading', { name: 'Tour Operators' })).toBeVisible();
await page.getByRole('button', { name: 'Add operator' }).click();
await page.getByPlaceholder('Search or type operator name').fill(operatorName);
await page.getByRole('button', { name: 'Add operator' }).last().click();
await expect(page.getByText(operatorName, { exact: true })).toBeVisible();
const operatorCard = page
.getByText(operatorName, { exact: true })
.locator('xpath=ancestor::div[contains(@class,"rounded-xl")]');
await operatorCard.getByRole('link', { name: 'Tours' }).click();
await expect(page.getByRole('heading', { name: operatorName })).toBeVisible();
await page.getByRole('button', { name: 'Import' }).click();
const importDialog = page.getByRole('dialog', { name: 'Import tours' });
await expect(importDialog).toBeVisible();
await importDialog.getByPlaceholder('Search tours...').fill('a');
const results = importDialog.locator('ul button');
await expect(results.first()).toBeVisible();
const tourTitle = (await results.first().innerText()).trim();
await results.first().click();
await importDialog.getByRole('button', { name: 'Import tour' }).click();
await expect(importDialog).toBeHidden();
await expect(page.getByRole('link', { name: tourTitle })).toBeVisible();
await page.context().clearCookies();
await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await createTrip(page, { name: `E2E Tour Trip ${suffix}` });
await openAddToTripMenuItem(page, 'Package Tours');
const addDialog = page.getByRole('dialog', { name: 'Add package tour' });
await expect(addDialog).toBeVisible();
await addDialog.getByPlaceholder('Search or type operator name').fill(operatorName);
await addDialog.getByRole('button', { name: operatorName }).click();
await expect(addDialog.locator('select')).toContainText(tourTitle);
await addDialog.locator('select').first().selectOption({ label: tourTitle });
await addDialog.getByRole('button', { name: 'Add tour' }).click();
await expect(addDialog).toBeHidden();
await expect(page.getByText(tourTitle, { exact: true })).toBeVisible();
});

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;

View File

@@ -0,0 +1,79 @@
import { test, expect } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js';
import { loginAsLocalUser } from './helpers/auth.js';
import { uniqueSuffix } from './helpers/trip.js';
const DASHBOARD_URL = '/trips/dashboard';
const ADMIN_USERS_URL = '/trips/admin/users';
async function expectDashboardLoaded(page: Parameters<typeof loginAsLocalUser>[0]): Promise<void> {
await expect(page).toHaveURL(/\/trips\/dashboard/);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
}
test.beforeEach(async ({ context }) => {
await context.clearCookies();
});
test('regular user smoke', async ({ page }) => {
await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await expectDashboardLoaded(page);
await page.goto(ADMIN_USERS_URL);
await expect(page).toHaveURL(/\/trips\/dashboard/);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
test('admin user smoke', async ({ page }) => {
await loginAsLocalUser(page, TEST_USERS.admin.username, TEST_USERS.admin.password);
await expectDashboardLoaded(page);
await page.goto(ADMIN_USERS_URL);
await expect(page).toHaveURL(/\/trips\/admin\/users/);
await expect(page.getByRole('heading', { name: 'Users' })).toBeVisible();
});
test('disposable local user smoke', async ({ page }) => {
const suffix = uniqueSuffix();
const username = `e2e_disposable_${suffix}`;
const fullName = `E2E Disposable ${suffix}`;
const email = `e2e_disposable_${suffix}@test.local`;
const password = `TempPass-${suffix}-1234`;
await loginAsLocalUser(page, TEST_USERS.admin.username, TEST_USERS.admin.password);
await page.goto(ADMIN_USERS_URL);
await expect(page.getByRole('heading', { name: 'Users' })).toBeVisible();
await page.getByRole('button', { name: 'Add User' }).click();
const addDialog = page
.locator('[role="dialog"]')
.filter({ has: page.getByRole('heading', { name: 'Add user' }) })
.first();
await expect(addDialog).toBeVisible();
await addDialog.locator('input[type="text"]').nth(0).fill(username);
await addDialog.locator('input[type="text"]').nth(1).fill(fullName);
await addDialog.getByPlaceholder('name@example.com').fill(email);
await addDialog.locator('input[type="password"]').nth(0).fill(password);
await addDialog.locator('input[type="password"]').nth(1).fill(password);
const createResponse = page.waitForResponse((response) => {
return (
response.url().includes('/admin/api/users') &&
response.request().method() === 'POST' &&
response.ok()
);
});
await addDialog.getByRole('button', { name: 'Create user' }).click();
await createResponse;
await expect(page.getByRole('cell', { name: username, exact: true })).toBeVisible();
await page.context().clearCookies();
await loginAsLocalUser(page, username, password);
await expectDashboardLoaded(page);
await page.goto(ADMIN_USERS_URL);
await expect(page).toHaveURL(/\/trips\/dashboard/);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});

View File

@@ -0,0 +1,51 @@
import { test, expect } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js';
import { loginAsLocalUser } from './helpers/auth.js';
import { addFlight, createTrip, uniqueSuffix } from './helpers/trip.js';
function formatDate(offsetDays: number): string {
const date = new Date();
date.setDate(date.getDate() + offsetDays);
return date.toISOString().slice(0, 10);
}
test.beforeEach(async ({ context }) => {
await context.clearCookies();
});
test('transportation lifecycle updates the trip view', async ({ page }) => {
const suffix = uniqueSuffix();
const tripName = `E2E Transport ${suffix}`;
const departureDate = formatDate(30);
await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await createTrip(page, {
name: tripName,
startDate: departureDate,
description: 'Transportation lifecycle coverage'
});
await addFlight(page, {
departureDate,
airlineCode: 'UA',
flightNumber: '1001',
departureAirport: 'SFO',
arrivalAirport: 'LAX'
});
await expect(page.getByRole('heading', { name: 'Transportation' })).toBeVisible();
await expect(page.getByText(/UA\s*1001/)).toBeVisible();
await page.getByRole('button', { name: 'Edit transportation' }).click();
const editDialog = page.getByRole('dialog', { name: 'Edit transportation' });
await expect(editDialog).toBeVisible();
await editDialog.locator('input[name="segments[0][flight_number]"]').fill('1002');
await editDialog.getByRole('button', { name: 'Save changes' }).click();
await expect(editDialog).toBeHidden();
await expect(page.getByText(/UA\s*1002/)).toBeVisible();
await expect(page.getByText(/UA\s*1001/)).toHaveCount(0);
await page.getByRole('button', { name: 'Remove transportation' }).click();
await expect(page.getByRole('heading', { name: 'Transportation' })).toHaveCount(0);
});

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 with no end date', 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

@@ -4,26 +4,32 @@
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"dev": "bunx --bun vite dev",
"build": "bunx --bun vite build",
"preview": "bunx --bun vite preview",
"prepare": "bunx --bun svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint src",
"lint": "biome lint .",
"format": "prettier --write .",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
"test": "bunx --bun svelte-kit sync && bunx --bun vitest run",
"test:watch": "bunx --bun svelte-kit sync && bunx --bun vitest",
"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": {
"@biomejs/biome": "^2.4.4",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.58.2",
"@sveltejs/adapter-node": "^5.5.3",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.0",
"@types/better-sqlite3": "^7.6.13",
"@vitest/coverage-v8": "^4.0.18",
"bun-types": "^1.3.9",
"dotenv": "^17.3.1",
"eslint": "^10.0.0",
"eslint-plugin-svelte": "^3.15.0",
"globals": "^17.3.0",
@@ -40,6 +46,6 @@
},
"dependencies": {
"@auth/sveltekit": "^1.11.1",
"better-sqlite3": "^12.6.2"
"argon2": "^0.41.1"
}
}

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

@@ -1,5 +1,8 @@
import { SvelteKitAuth } from '@auth/sveltekit';
import Credentials from '@auth/core/providers/credentials';
import { env } from '$env/dynamic/private';
import { upsertUserFromAuth } from '$lib/server/users.js';
import { verifyLocalCredentials } from '$lib/server/local-auth.js';
export const { handle, signIn, signOut } = SvelteKitAuth({
providers: [
@@ -18,12 +21,59 @@ export const { handle, signIn, signOut } = SvelteKitAuth({
email: profile.email as string | undefined
};
}
}
},
Credentials({
id: 'local',
name: 'Local',
credentials: {
identifier: { label: 'Username or email', type: 'text' },
password: { label: 'Password', type: 'password' }
},
async authorize(credentials, request) {
const identifier = String(credentials?.identifier ?? '').trim();
const password = String(credentials?.password ?? '').trim();
const ip =
request?.headers?.get?.('x-forwarded-for') ??
request?.headers?.get?.('x-real-ip') ??
undefined;
const result = await verifyLocalCredentials(identifier, password, ip);
if (result.status !== 'success') return null;
return {
id: result.user.id,
name: result.user.name,
email: result.user.email ?? undefined
};
}
})
],
trustHost: true,
pages: {
signIn: '/trips/login'
},
callbacks: {
jwt({ token, profile }) {
if (profile?.sub) token.sub = profile.sub as string;
jwt({ token, profile, user }) {
const details = profile as
| {
sub?: string;
name?: string;
email?: string;
username?: string;
preferred_username?: string;
}
| undefined;
if (details?.sub) {
token.sub = details.sub as string;
upsertUserFromAuth({
id: details.sub,
username: details.username ?? details.preferred_username ?? details.name,
fullName: details.name ?? details.username ?? details.preferred_username,
email: details.email,
authSource: 'OIDC - Synology'
});
}
if (user?.id) {
token.sub = user.id as string;
}
return token;
},
session({ session, token }) {

View File

@@ -42,6 +42,15 @@
General
</a>
<a
href="{base}/admin/users"
class="rounded-md px-3 py-2 text-sm transition-colors {isActive('/admin/users')
? 'bg-gray-100 font-medium text-gray-900'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'}"
>
Users
</a>
<p class="mt-4 px-3 pb-1 text-xs font-semibold tracking-wider text-gray-400 uppercase">
Reference Data
</p>

View File

@@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { setupTestDb } from '../../../tests/helpers.js';
import type { Database } from '../db/types.js';
import { upsertUserFromAuth } from '../users.js';
import { isAdminUser, requireAdmin } from './auth.js';
let database: Database;
const originalAdminUserIds = process.env.ADMIN_USER_IDS;
beforeEach(() => {
database = setupTestDb();
process.env.ADMIN_USER_IDS = '';
});
afterEach(() => {
process.env.ADMIN_USER_IDS = originalAdminUserIds;
database.close();
});
describe('isAdminUser', () => {
it('allows direct ID matches', () => {
process.env.ADMIN_USER_IDS = 'oidc-sub-123';
expect(isAdminUser('oidc-sub-123')).toBe(true);
});
it('allows username matches for known users', () => {
upsertUserFromAuth({
id: 'oidc-sub-123',
username: 'shaun',
fullName: 'Shaun Campbell',
email: 'shaun@example.com'
});
process.env.ADMIN_USER_IDS = 'shaun';
expect(isAdminUser('oidc-sub-123')).toBe(true);
});
it('allows email matches for known users', () => {
upsertUserFromAuth({
id: 'oidc-sub-123',
username: 'shaun',
fullName: 'Shaun Campbell',
email: 'shaun@example.com'
});
process.env.ADMIN_USER_IDS = 'shaun@example.com';
expect(isAdminUser('oidc-sub-123')).toBe(true);
});
it('allows email local-part matches from session user', () => {
process.env.ADMIN_USER_IDS = 'shaun';
expect(isAdminUser({ id: 'oidc-sub-123', email: 'shaun@example.com' })).toBe(true);
});
});
describe('requireAdmin', () => {
it('throws when not authenticated', () => {
process.env.ADMIN_USER_IDS = 'shaun';
expect(() => requireAdmin(undefined)).toThrow('Not authenticated');
});
it('throws when user is not admin', () => {
process.env.ADMIN_USER_IDS = 'shaun';
expect(() => requireAdmin('someone-else')).toThrow('Admin access required');
});
it('does not throw when username match grants admin access', () => {
upsertUserFromAuth({
id: 'oidc-sub-123',
username: 'shaun',
fullName: 'Shaun Campbell'
});
process.env.ADMIN_USER_IDS = 'shaun';
expect(() => requireAdmin('oidc-sub-123')).not.toThrow();
});
it('does not throw when session user email local-part matches', () => {
process.env.ADMIN_USER_IDS = 'shaun';
expect(() =>
requireAdmin({ id: 'oidc-sub-123', email: 'shaun@example.com', name: 'Shaun Campbell' })
).not.toThrow();
});
});

View File

@@ -1,12 +1,73 @@
import { env } from '$env/dynamic/private';
import { db } from '$lib/server/db/index.js';
export function requireAdmin(userId: string | undefined): void {
if (!userId) throw new Error('Not authenticated');
const adminIds = (env.ADMIN_USER_IDS ?? '')
const getAdminIdentifiers = (): string[] =>
(process.env.ADMIN_USER_IDS ?? env.ADMIN_USER_IDS ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (adminIds.length === 0 || !adminIds.includes(userId)) {
type AdminPrincipal =
| string
| {
id?: string | null;
name?: string | null;
email?: string | null;
}
| undefined;
const normalize = (value: string): string => value.trim().toLowerCase();
const getPrincipalValues = (principal: AdminPrincipal): { userId?: string; values: string[] } => {
if (!principal) return { values: [] };
if (typeof principal === 'string') {
const value = principal.trim();
return value ? { userId: value, values: [value] } : { values: [] };
}
const values = [principal.id, principal.name, principal.email]
.filter((value): value is string => Boolean(value?.trim()))
.map((value) => value.trim());
const localPart = principal.email?.split('@')[0]?.trim();
if (localPart) values.push(localPart);
const userId = principal.id?.trim();
return { userId, values };
};
export function isAdminUser(principal: AdminPrincipal): boolean {
const { userId, values } = getPrincipalValues(principal);
if (values.length === 0) return false;
const adminIds = getAdminIdentifiers();
if (adminIds.length === 0) return false;
const normalizedAdminIds = new Set(adminIds.map(normalize));
for (const value of values) {
if (adminIds.includes(value) || normalizedAdminIds.has(normalize(value))) return true;
}
if (!userId) return false;
const user = db.get<{ username: string; email: string | null }>(
'SELECT username, email FROM users WHERE id = ?',
[userId]
);
if (!user) return false;
const dbValues = [user.username, user.email, user.email?.split('@')[0]]
.filter((value): value is string => Boolean(value?.trim()))
.map((value) => value.trim());
for (const value of dbValues) {
if (adminIds.includes(value) || normalizedAdminIds.has(normalize(value))) return true;
}
return false;
}
export function requireAdmin(principal: AdminPrincipal): void {
if (!principal) throw new Error('Not authenticated');
if (!isAdminUser(principal)) {
throw new Error('Admin access required');
}
}

View File

@@ -3,10 +3,12 @@ import { createSqliteDb } from './sqlite.js';
import { runMigrations } from './migrations.js';
import type { Database } from './types.js';
function createDb(): Database {
const url = env.DATABASE_URL ?? 'file:trips.db';
const isVitest = process.env.VITEST === 'true';
if (url.startsWith('file:') || url.endsWith('.db')) {
function createDb(): Database {
const url = isVitest ? ':memory:' : (env.DATABASE_URL ?? 'file:trips.db');
if (url === ':memory:' || url.startsWith('file:') || url.endsWith('.db')) {
return createSqliteDb(url);
}
@@ -17,7 +19,9 @@ function createDb(): Database {
export let db: Database = createDb();
runMigrations(db);
if (!isVitest) {
runMigrations(db);
}
/**
* Replace the database singleton. For use in tests only — call this with an

View File

@@ -53,6 +53,27 @@ export function runMigrations(db: Database): void {
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
full_name TEXT NOT NULL,
email TEXT,
auth_source TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS local_credentials (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS cities (
id INTEGER PRIMARY KEY,

View File

@@ -1,23 +1,28 @@
import BetterSqlite3 from 'better-sqlite3';
import { Database as BunSqliteDatabase } from 'bun:sqlite';
import type { Database } from './types.js';
export function createSqliteDb(url: string): Database {
// Strip the "file:" prefix if present
const path = url.startsWith('file:') ? url.slice(5) : url;
const db = new BetterSqlite3(path);
const db = new BunSqliteDatabase(path);
// Enable WAL mode for better concurrent read performance
db.pragma('journal_mode = WAL');
try {
db.exec('PRAGMA journal_mode = WAL');
} catch {
// This can fail when multiple processes initialize the same DB concurrently.
}
return {
run(sql, params = []) {
db.prepare(sql).run(params);
db.query(sql).run(...params);
},
get<T = Record<string, unknown>>(sql: string, params: unknown[] = []) {
return db.prepare(sql).get(params) as T | undefined;
const row = db.query(sql).get(...params);
return (row === null ? undefined : row) as T | undefined;
},
all<T = Record<string, unknown>>(sql: string, params: unknown[] = []) {
return db.prepare(sql).all(params) as T[];
return db.query(sql).all(...params) as T[];
},
close() {
db.close();

View File

@@ -0,0 +1,84 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { setupTestDb } from '../../tests/helpers.js';
import type { Database } from './db/types.js';
import { setLocalCredentialPassword } from './local-credentials.js';
import { resetLocalAuthRateLimits, verifyLocalCredentials } from './local-auth.js';
import { upsertUserFromAuth } from './users.js';
let database: Database;
beforeEach(() => {
process.env.LOCAL_AUTH_ENABLED = 'true';
process.env.LOCAL_AUTH_ARGON2_MEMORY_KB = '8192';
process.env.LOCAL_AUTH_ARGON2_TIME_COST = '2';
process.env.LOCAL_AUTH_ARGON2_PARALLELISM = '1';
process.env.LOCAL_AUTH_MAX_ATTEMPTS = '2';
process.env.LOCAL_AUTH_WINDOW_SECONDS = '60';
process.env.LOCAL_AUTH_LOCKOUT_SECONDS = '10';
database = setupTestDb();
resetLocalAuthRateLimits();
});
afterEach(() => {
vi.useRealTimers();
resetLocalAuthRateLimits();
database.close();
});
describe('verifyLocalCredentials', () => {
it('authenticates valid local credentials', async () => {
upsertUserFromAuth({
id: 'user-1',
username: 'jdoe',
fullName: 'Jane Doe',
email: 'jane@example.com',
authSource: 'Local'
});
await setLocalCredentialPassword('user-1', 'averysecurepassword');
const result = await verifyLocalCredentials('jdoe', 'averysecurepassword', '127.0.0.1');
expect(result.status).toBe('success');
if (result.status === 'success') {
expect(result.user.id).toBe('user-1');
expect(result.user.email).toBe('jane@example.com');
}
});
it('returns invalid for bad credentials and locks after max attempts', async () => {
upsertUserFromAuth({ id: 'user-2', username: 'sally', fullName: 'Sally Sample' });
await setLocalCredentialPassword('user-2', 'averysecurepassword');
const first = await verifyLocalCredentials('sally', 'wrong-password', '10.0.0.1');
const second = await verifyLocalCredentials('sally', 'wrong-password', '10.0.0.1');
const third = await verifyLocalCredentials('sally', 'wrong-password', '10.0.0.1');
expect(first.status).toBe('invalid');
expect(second.status).toBe('invalid');
expect(third.status).toBe('locked');
});
it('clears lockout after window expires', async () => {
upsertUserFromAuth({ id: 'user-3', username: 'morgan', fullName: 'Morgan West' });
await setLocalCredentialPassword('user-3', 'averysecurepassword');
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
await verifyLocalCredentials('morgan', 'wrong-password', '10.0.0.2');
await verifyLocalCredentials('morgan', 'wrong-password', '10.0.0.2');
const locked = await verifyLocalCredentials('morgan', 'averysecurepassword', '10.0.0.2');
expect(locked.status).toBe('locked');
vi.advanceTimersByTime(11_000);
const after = await verifyLocalCredentials('morgan', 'averysecurepassword', '10.0.0.2');
expect(after.status).toBe('success');
});
it('rejects when local auth is disabled', async () => {
process.env.LOCAL_AUTH_ENABLED = 'false';
const result = await verifyLocalCredentials('anyone', 'password', '10.0.0.3');
expect(result.status).toBe('disabled');
});
});

View File

@@ -0,0 +1,154 @@
import { env } from '$env/dynamic/private';
import argon2 from 'argon2';
import { getLocalCredentialByIdentifier, hashLocalPassword } from './local-credentials.js';
export interface LocalAuthUser {
id: string;
name: string;
email?: string | null;
}
export type LocalAuthResult =
| { status: 'success'; user: LocalAuthUser }
| { status: 'invalid' | 'locked' | 'disabled' };
interface AttemptState {
count: number;
firstAttemptAt: number;
lockedUntil?: number;
}
const attemptsByIdentifier = new Map<string, AttemptState>();
const attemptsByIp = new Map<string, AttemptState>();
let dummyHashPromise: Promise<string> | null = null;
const getEnvValue = (key: keyof typeof env): string | undefined => {
return process.env[key] ?? env[key];
};
const getNumberEnv = (value: string | undefined, fallback: number): number => {
const parsed = Number.parseInt(value ?? '', 10);
return Number.isFinite(parsed) ? parsed : fallback;
};
const getLocalAuthConfig = () => {
return {
enabled: getEnvValue('LOCAL_AUTH_ENABLED') === 'true',
maxAttempts: getNumberEnv(getEnvValue('LOCAL_AUTH_MAX_ATTEMPTS'), 5),
windowMs: getNumberEnv(getEnvValue('LOCAL_AUTH_WINDOW_SECONDS'), 900) * 1000,
lockoutMs: getNumberEnv(getEnvValue('LOCAL_AUTH_LOCKOUT_SECONDS'), 900) * 1000
};
};
const normalizeIdentifier = (value: string): string => value.trim().toLowerCase();
const normalizePassword = (value: string): string => value.trim();
const normalizeIp = (value?: string | null): string => {
if (!value) return 'unknown';
const trimmed = value.trim();
if (!trimmed) return 'unknown';
return trimmed.split(',')[0]?.trim() || 'unknown';
};
const getDummyHash = async (): Promise<string> => {
if (!dummyHashPromise) {
dummyHashPromise = hashLocalPassword('invalid-password-placeholder');
}
return dummyHashPromise;
};
const getEffectiveState = (state: AttemptState | undefined, now: number, windowMs: number) => {
if (!state) return { count: 0, firstAttemptAt: now };
if (state.lockedUntil && state.lockedUntil <= now) {
return { count: 0, firstAttemptAt: now };
}
if (now - state.firstAttemptAt > windowMs) {
return { count: 0, firstAttemptAt: now };
}
return { ...state };
};
const isLocked = (state: AttemptState, now: number): boolean => {
return Boolean(state.lockedUntil && state.lockedUntil > now);
};
const recordFailure = (
map: Map<string, AttemptState>,
key: string,
now: number,
windowMs: number,
lockoutMs: number,
maxAttempts: number
): void => {
const state = getEffectiveState(map.get(key), now, windowMs);
const nextCount = state.count + 1;
const nextState: AttemptState = {
count: nextCount,
firstAttemptAt: state.firstAttemptAt
};
if (nextCount >= maxAttempts) {
nextState.lockedUntil = now + lockoutMs;
}
map.set(key, nextState);
};
const clearAttemptState = (map: Map<string, AttemptState>, key: string): void => {
map.delete(key);
};
export function resetLocalAuthRateLimits(): void {
attemptsByIdentifier.clear();
attemptsByIp.clear();
}
export async function verifyLocalCredentials(
identifierInput: string,
passwordInput: string,
ipAddress?: string | null
): Promise<LocalAuthResult> {
const config = getLocalAuthConfig();
if (!config.enabled) return { status: 'disabled' };
const identifier = normalizeIdentifier(identifierInput ?? '');
const password = normalizePassword(passwordInput ?? '');
const identifierKey = identifier || 'unknown';
const ipKey = normalizeIp(ipAddress);
const now = Date.now();
const identifierState = getEffectiveState(
attemptsByIdentifier.get(identifierKey),
now,
config.windowMs
);
const ipState = getEffectiveState(attemptsByIp.get(ipKey), now, config.windowMs);
const locked = isLocked(identifierState, now) || isLocked(ipState, now);
const record = identifier ? getLocalCredentialByIdentifier(identifier) : null;
const passwordHash = record?.passwordHash ?? (await getDummyHash());
const passwordMatches = await argon2.verify(passwordHash, password);
if (!locked && record && passwordMatches) {
clearAttemptState(attemptsByIdentifier, identifierKey);
clearAttemptState(attemptsByIp, ipKey);
return {
status: 'success',
user: {
id: record.userId,
name: record.fullName || record.username,
email: record.email ?? undefined
}
};
}
recordFailure(
attemptsByIdentifier,
identifierKey,
now,
config.windowMs,
config.lockoutMs,
config.maxAttempts
);
recordFailure(attemptsByIp, ipKey, now, config.windowMs, config.lockoutMs, config.maxAttempts);
return { status: locked ? 'locked' : 'invalid' };
}

View File

@@ -0,0 +1,67 @@
import { env } from '$env/dynamic/private';
import argon2 from 'argon2';
import { db } from './db/index.js';
export interface LocalCredentialRecord {
userId: string;
username: string;
fullName: string;
email: string | null;
passwordHash: string;
}
export const LOCAL_AUTH_MIN_PASSWORD_LENGTH = 12;
const getNumberEnv = (value: string | undefined, fallback: number): number => {
const parsed = Number.parseInt(value ?? '', 10);
return Number.isFinite(parsed) ? parsed : fallback;
};
export function getLocalCredentialByIdentifier(identifier: string): LocalCredentialRecord | null {
const normalized = identifier.trim();
if (!normalized) return null;
return (
db.get<LocalCredentialRecord>(
`SELECT
users.id as userId,
users.username as username,
users.full_name as fullName,
users.email as email,
local_credentials.password_hash as passwordHash
FROM users
INNER JOIN local_credentials ON local_credentials.user_id = users.id
WHERE lower(users.username) = lower(?)
OR (users.email IS NOT NULL AND lower(users.email) = lower(?))
LIMIT 1`,
[normalized, normalized]
) ?? null
);
}
export async function hashLocalPassword(password: string): Promise<string> {
const trimmed = password.trim();
if (trimmed.length < LOCAL_AUTH_MIN_PASSWORD_LENGTH) {
throw new Error(`Password must be at least ${LOCAL_AUTH_MIN_PASSWORD_LENGTH} characters`);
}
const memoryCost = getNumberEnv(env.LOCAL_AUTH_ARGON2_MEMORY_KB, 65536);
const timeCost = getNumberEnv(env.LOCAL_AUTH_ARGON2_TIME_COST, 3);
const parallelism = getNumberEnv(env.LOCAL_AUTH_ARGON2_PARALLELISM, 1);
return argon2.hash(trimmed, {
type: argon2.argon2id,
memoryCost,
timeCost,
parallelism
});
}
export async function setLocalCredentialPassword(userId: string, password: string): Promise<void> {
const passwordHash = await hashLocalPassword(password);
db.run(
`INSERT INTO local_credentials (user_id, password_hash)
VALUES (?, ?)
ON CONFLICT(user_id) DO UPDATE SET
password_hash = excluded.password_hash,
updated_at = datetime('now')`,
[userId, passwordHash]
);
}

View File

@@ -1,5 +1,6 @@
import { db } from './db/index.js';
import { randomUUID } from 'crypto';
import { upsertUserProfile } from './users.js';
export interface Person {
id: string;
@@ -33,6 +34,7 @@ export function upsertSelfProfile(
WHERE id = ?`,
[firstName, lastName, email ?? null, existing.id]
);
upsertUserProfile(userId, `${firstName} ${lastName}`.trim(), email ?? null);
return db.get<Person>('SELECT * FROM people WHERE id = ?', [existing.id])!;
}
const id = randomUUID();
@@ -41,6 +43,7 @@ export function upsertSelfProfile(
VALUES (?, ?, ?, ?, ?, 1)`,
[id, userId, firstName, lastName, email ?? null]
);
upsertUserProfile(userId, `${firstName} ${lastName}`.trim(), email ?? null);
return db.get<Person>('SELECT * FROM people WHERE id = ?', [id])!;
}

View File

@@ -0,0 +1,151 @@
import { beforeEach, afterEach, describe, expect, it } from 'vitest';
import { setupTestDb } from '../../tests/helpers.js';
import type { Database } from './db/types.js';
import {
createLocalUser,
deleteUser,
listUsers,
setLocalPasswordForUser,
updateUser,
upsertUserFromAuth,
upsertUserProfile
} from './users.js';
let database: Database;
beforeEach(() => {
database = setupTestDb();
});
afterEach(() => {
database.close();
});
describe('upsertUserFromAuth', () => {
it('creates a new user with profile data', () => {
const created = upsertUserFromAuth({
id: 'u1',
username: 'jdoe',
fullName: 'Jane Doe',
email: 'jane@example.com',
authSource: 'OIDC - Synology'
});
expect(created.id).toBe('u1');
expect(created.username).toBe('jdoe');
expect(created.full_name).toBe('Jane Doe');
expect(created.email).toBe('jane@example.com');
expect(created.auth_source).toBe('OIDC - Synology');
expect(listUsers()).toHaveLength(1);
});
it('updates existing users with new auth data', () => {
upsertUserFromAuth({
id: 'u1',
username: 'jdoe',
fullName: 'Jane Doe',
email: 'jane@example.com',
authSource: 'OIDC - Synology'
});
const updated = upsertUserFromAuth({
id: 'u1',
username: 'janed',
email: 'jane.doe@example.com'
});
expect(updated.username).toBe('janed');
expect(updated.email).toBe('jane.doe@example.com');
});
});
describe('upsertUserProfile', () => {
it('creates a user from profile details', () => {
const created = upsertUserProfile('u2', 'Sam Sample', 'sam@example.com');
expect(created.id).toBe('u2');
expect(created.full_name).toBe('Sam Sample');
expect(created.email).toBe('sam@example.com');
});
});
describe('updateUser', () => {
it('updates user fields and syncs self profile when present', () => {
upsertUserFromAuth({
id: 'u3',
username: 'sarah',
fullName: 'Sarah Lee',
email: 'sarah@example.com'
});
database.run(
`INSERT INTO people (id, user_id, first_name, last_name, email, is_self)
VALUES (?, ?, ?, ?, ?, 1)`,
['p1', 'u3', 'Sarah', 'Lee', 'sarah@example.com']
);
updateUser({ id: 'u3', username: 'slee', fullName: 'Sarah Smith', email: 'ss@example.com' });
const user = listUsers().find((item) => item.id === 'u3');
expect(user?.username).toBe('slee');
expect(user?.full_name).toBe('Sarah Smith');
expect(user?.email).toBe('ss@example.com');
const profile = database.get<{ first_name: string; last_name: string; email: string | null }>(
'SELECT first_name, last_name, email FROM people WHERE user_id = ? AND is_self = 1',
['u3']
);
expect(profile?.first_name).toBe('Sarah');
expect(profile?.last_name).toBe('Smith');
expect(profile?.email).toBe('ss@example.com');
});
});
describe('deleteUser', () => {
it('removes the user record', () => {
upsertUserFromAuth({ id: 'u4', username: 'delete-me', fullName: 'Delete Me' });
deleteUser('u4');
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();
});
});

195
src/lib/server/users.ts Normal file
View File

@@ -0,0 +1,195 @@
import { randomUUID } from 'crypto';
import { db } from './db/index.js';
import { setLocalCredentialPassword } from './local-credentials.js';
export interface AppUser {
id: string;
username: string;
full_name: string;
email: string | null;
auth_source: string;
created_at: string;
updated_at: string;
}
const DEFAULT_AUTH_SOURCE = 'OIDC - Synology';
const LOCAL_AUTH_SOURCE = 'Local';
const normalizeOptional = (value?: string | null): string | undefined => {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
};
const normalizeEmail = (value?: string | null): string | null | undefined => {
if (value === undefined) return undefined;
if (value === null) return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
};
export function listUsers(): (AppUser & { has_local_credentials: boolean })[] {
return db
.all<AppUser & { has_local_credentials: 0 | 1 }>(
`SELECT u.id, u.username, u.full_name, u.email, u.auth_source, u.created_at, u.updated_at,
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: {
id: string;
username?: string | null;
fullName?: string | null;
email?: string | null;
authSource?: string | null;
}): AppUser {
const existing = db.get<AppUser>('SELECT * FROM users WHERE id = ?', [input.id]);
const username = normalizeOptional(input.username) ?? existing?.username ?? input.id;
const fullName = normalizeOptional(input.fullName) ?? existing?.full_name ?? '';
const email = normalizeOptional(input.email) ?? existing?.email ?? null;
const authSource =
normalizeOptional(input.authSource) ?? existing?.auth_source ?? DEFAULT_AUTH_SOURCE;
if (existing) {
const setClauses: string[] = [];
const values: Array<string | null> = [];
if (username !== existing.username) {
setClauses.push('username = ?');
values.push(username);
}
if (fullName !== existing.full_name) {
setClauses.push('full_name = ?');
values.push(fullName);
}
if (email !== existing.email) {
setClauses.push('email = ?');
values.push(email);
}
if (authSource !== existing.auth_source) {
setClauses.push('auth_source = ?');
values.push(authSource);
}
if (setClauses.length > 0) {
setClauses.push("updated_at = datetime('now')");
values.push(input.id);
db.run(`UPDATE users SET ${setClauses.join(', ')} WHERE id = ?`, values);
}
} else {
db.run(
`INSERT INTO users (id, username, full_name, email, auth_source)
VALUES (?, ?, ?, ?, ?)`,
[input.id, username, fullName, email, authSource]
);
}
return db.get<AppUser>('SELECT * FROM users WHERE id = ?', [input.id])!;
}
export function upsertUserProfile(
userId: string,
fullName: string,
email?: string | null
): AppUser {
return upsertUserFromAuth({ id: userId, fullName, email, authSource: DEFAULT_AUTH_SOURCE });
}
export function updateUser(input: {
id: string;
username: string;
fullName: string;
email?: string | null;
}): void {
const existing = db.get<{ email: string | null }>('SELECT email FROM users WHERE id = ?', [
input.id
]);
const email = normalizeEmail(input.email);
const nextEmail = email === undefined ? existing?.email ?? null : email;
db.run(
`UPDATE users
SET username = ?, full_name = ?, email = ?, updated_at = datetime('now')
WHERE id = ?`,
[input.username.trim(), input.fullName.trim(), nextEmail, input.id]
);
syncSelfProfile(input.id, input.fullName, nextEmail);
}
export function deleteUser(id: string): void {
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 {
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`,
[userId]
);
if (!profile) return;
const trimmed = fullName.trim();
let firstName = profile.first_name;
let lastName = profile.last_name;
if (trimmed) {
const parts = trimmed.split(/\s+/);
firstName = parts[0] ?? profile.first_name;
if (parts.length > 1) {
lastName = parts.slice(1).join(' ');
}
}
const nextEmail = email === undefined ? profile.email : email;
db.run(
`UPDATE people SET first_name = ?, last_name = ?, email = ?, updated_at = datetime('now')
WHERE id = ?`,
[firstName, lastName, nextEmail, profile.id]
);
}

View File

@@ -1,6 +1,6 @@
import { redirect } from '@sveltejs/kit';
import { base } from '$app/paths';
import { env } from '$env/dynamic/private';
import { isAdminUser } from '$lib/server/admin/auth.js';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async (event) => {
@@ -9,11 +9,7 @@ export const load: LayoutServerLoad = async (event) => {
redirect(303, `${base}/login`);
}
const adminIds = (env.ADMIN_USER_IDS ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const isAdmin = adminIds.length > 0 && session.user.id && adminIds.includes(session.user.id);
const isAdmin = isAdminUser(session.user);
return { session, isAdmin };
};

View File

@@ -4,13 +4,13 @@ import { requireAdmin } from '$lib/server/admin/auth.js';
import * as data from '$lib/server/admin/data.js';
export const GET: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const q = event.url.searchParams.get('q') ?? undefined;
return json(data.listAirlines(q));
};
export const POST: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const body = await event.request.json();
const { name, country, country_code, iata_code, icao_code } = body as {
name?: string;
@@ -32,7 +32,7 @@ export const POST: RequestHandler = async (event) => {
};
export const PATCH: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const body = await event.request.json();
const { id, name, country, country_code, iata_code, icao_code } = body as {
id?: number;
@@ -55,7 +55,7 @@ export const PATCH: RequestHandler = async (event) => {
};
export const DELETE: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const id = parseInt(event.url.searchParams.get('id') ?? '');
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
data.deleteAirline(id);

View File

@@ -4,13 +4,13 @@ import { requireAdmin } from '$lib/server/admin/auth.js';
import * as data from '$lib/server/admin/data.js';
export const GET: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const q = event.url.searchParams.get('q') ?? undefined;
return json(data.listAirports(q));
};
export const POST: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const body = await event.request.json();
const { name, country, country_code, iata_code, icao_code, city, latitude, longitude, timezone } =
body as {
@@ -43,7 +43,7 @@ export const POST: RequestHandler = async (event) => {
};
export const PATCH: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const body = await event.request.json();
const {
id,
@@ -87,7 +87,7 @@ export const PATCH: RequestHandler = async (event) => {
};
export const DELETE: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const id = parseInt(event.url.searchParams.get('id') ?? '');
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
data.deleteAirport(id);

View File

@@ -4,14 +4,14 @@ import { requireAdmin } from '$lib/server/admin/auth.js';
import * as data from '$lib/server/admin/data.js';
export const GET: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const q = event.url.searchParams.get('q') ?? undefined;
const countryCode = event.url.searchParams.get('country_code') ?? undefined;
return json(data.listCities(q, countryCode));
};
export const POST: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const body = await event.request.json();
const { name, country, country_code, population } = body as {
name?: string;
@@ -26,7 +26,7 @@ export const POST: RequestHandler = async (event) => {
};
export const PATCH: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const body = await event.request.json();
const { id, name, country, country_code, population } = body as {
id?: number;
@@ -43,7 +43,7 @@ export const PATCH: RequestHandler = async (event) => {
};
export const DELETE: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const id = parseInt(event.url.searchParams.get('id') ?? '');
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
data.deleteCity(id);

View File

@@ -4,13 +4,13 @@ import { requireAdmin } from '$lib/server/admin/auth.js';
import * as data from '$lib/server/admin/data.js';
export const GET: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const q = event.url.searchParams.get('q') ?? undefined;
return json(data.listCountries(q));
};
export const POST: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const body = await event.request.json();
const { name, country_code } = body as { name?: string; country_code?: string };
if (!name?.trim() || !country_code?.trim()) {
@@ -20,7 +20,7 @@ export const POST: RequestHandler = async (event) => {
};
export const PATCH: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const body = await event.request.json();
const { id, name, country_code } = body as { id?: number; name?: string; country_code?: string };
if (id == null || !name?.trim() || !country_code?.trim()) {
@@ -31,7 +31,7 @@ export const PATCH: RequestHandler = async (event) => {
};
export const DELETE: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const id = parseInt(event.url.searchParams.get('id') ?? '');
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
data.deleteCountry(id);

View File

@@ -4,14 +4,14 @@ import { requireAdmin } from '$lib/server/admin/auth.js';
import * as data from '$lib/server/admin/data.js';
export const GET: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const tourId = parseInt(event.url.searchParams.get('operator_tour_id') ?? '');
if (isNaN(tourId)) return json({ error: 'operator_tour_id required' }, { status: 400 });
return json(data.listDaysForOperatorTour(tourId));
};
export const POST: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const body = await event.request.json();
const { operator_tour_id, title, notes } = body as {
operator_tour_id?: number;
@@ -25,7 +25,7 @@ export const POST: RequestHandler = async (event) => {
};
export const PATCH: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const body = await event.request.json();
const { id, title, notes } = body as { id?: number; title?: string; notes?: string };
if (id == null) return json({ error: 'id is required' }, { status: 400 });
@@ -34,7 +34,7 @@ export const PATCH: RequestHandler = async (event) => {
};
export const DELETE: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const id = parseInt(event.url.searchParams.get('id') ?? '');
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
data.deleteOperatorTourDay(id);

View File

@@ -4,14 +4,14 @@ import { requireAdmin } from '$lib/server/admin/auth.js';
import * as data from '$lib/server/admin/data.js';
export const GET: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const operatorId = parseInt(event.url.searchParams.get('operator_id') ?? '');
if (isNaN(operatorId)) return json({ error: 'operator_id required' }, { status: 400 });
return json(data.listToursForOperator(operatorId));
};
export const POST: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const body = await event.request.json();
const { operator_id, name } = body as { operator_id?: number; name?: string };
if (operator_id == null || !name?.trim()) {
@@ -21,7 +21,7 @@ export const POST: RequestHandler = async (event) => {
};
export const PATCH: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const body = await event.request.json();
const { id, name } = body as { id?: number; name?: string };
if (id == null || !name?.trim()) {
@@ -32,7 +32,7 @@ export const PATCH: RequestHandler = async (event) => {
};
export const DELETE: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const id = parseInt(event.url.searchParams.get('id') ?? '');
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
data.deleteOperatorTour(id);

View File

@@ -5,7 +5,7 @@ import { listTourOperators } from '$lib/server/admin/data.js';
import { getProviderForOperator } from '$lib/server/admin/providers/index.js';
export const GET: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const operatorId = parseInt(event.url.searchParams.get('operator_id') ?? '');
if (isNaN(operatorId)) return json({ error: 'operator_id required' }, { status: 400 });

View File

@@ -4,13 +4,13 @@ import { requireAdmin } from '$lib/server/admin/auth.js';
import * as data from '$lib/server/admin/data.js';
export const GET: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const q = event.url.searchParams.get('q') ?? undefined;
return json(data.listTourOperators(q));
};
export const POST: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const body = await event.request.json();
const { name, website, highlight_color } = body as {
name?: string;
@@ -24,7 +24,7 @@ export const POST: RequestHandler = async (event) => {
};
export const PATCH: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const body = await event.request.json();
const { id, name, website, highlight_color } = body as {
id?: number;
@@ -40,7 +40,7 @@ export const PATCH: RequestHandler = async (event) => {
};
export const DELETE: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const id = parseInt(event.url.searchParams.get('id') ?? '');
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
data.deleteTourOperator(id);

View File

@@ -0,0 +1,61 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/admin/auth.js';
import { createLocalUser, deleteUser, listUsers, updateUser } from '$lib/server/users.js';
export const GET: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user);
return json(listUsers());
};
export const PATCH: RequestHandler = async (event) => {
requireAdmin((await event.locals.auth())?.user);
const body = await event.request.json();
const { id, username, full_name, email } = body as {
id?: string;
username?: string;
full_name?: string;
email?: string | null;
};
if (!id?.trim() || !username?.trim() || !full_name?.trim()) {
return json({ error: 'id, username and full_name required' }, { status: 400 });
}
updateUser({ id: id.trim(), username, fullName: full_name, email });
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) => {
requireAdmin((await event.locals.auth())?.user);
const id = event.url.searchParams.get('id')?.trim();
if (!id) return json({ error: 'id required' }, { status: 400 });
deleteUser(id);
return json({ ok: true });
};

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

@@ -5,7 +5,7 @@ import { getProviderForOperator } from '$lib/server/admin/providers/index.js';
import type { PageServerLoad, Actions } from './$types';
export const load: PageServerLoad = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const operatorId = parseInt(event.params.operatorId);
if (isNaN(operatorId)) error(404, 'Not found');
@@ -27,7 +27,7 @@ export const load: PageServerLoad = async (event) => {
export const actions: Actions = {
addTour: async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const operatorId = parseInt(event.params.operatorId);
if (isNaN(operatorId)) return fail(400, { error: 'Invalid operator ID' });
@@ -45,7 +45,7 @@ export const actions: Actions = {
},
editTour: async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const formData = await event.request.formData();
const id = parseInt(formData.get('id') as string);
@@ -63,7 +63,7 @@ export const actions: Actions = {
},
deleteTour: async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const formData = await event.request.formData();
const id = parseInt(formData.get('id') as string);
@@ -78,7 +78,7 @@ export const actions: Actions = {
},
importTour: async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const operatorId = parseInt(event.params.operatorId);
if (isNaN(operatorId)) return fail(400, { error: 'Invalid operator ID' });

View File

@@ -4,7 +4,7 @@ import * as data from '$lib/server/admin/data.js';
import type { PageServerLoad, Actions } from './$types';
export const load: PageServerLoad = async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const operatorId = parseInt(event.params.operatorId);
const tourId = parseInt(event.params.tourId);
@@ -27,7 +27,7 @@ export const load: PageServerLoad = async (event) => {
export const actions: Actions = {
addDay: async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const tourId = parseInt(event.params.tourId);
if (isNaN(tourId)) return fail(400, { error: 'Invalid tour ID' });
@@ -45,7 +45,7 @@ export const actions: Actions = {
},
editDay: async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const formData = await event.request.formData();
const id = parseInt(formData.get('id') as string);
@@ -63,7 +63,7 @@ export const actions: Actions = {
},
deleteDay: async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const formData = await event.request.formData();
const id = parseInt(formData.get('id') as string);
@@ -78,7 +78,7 @@ export const actions: Actions = {
},
addDayPlan: async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const formData = await event.request.formData();
const dayId = parseInt(formData.get('dayId') as string);
@@ -247,7 +247,7 @@ export const actions: Actions = {
},
editDayPlan: async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const formData = await event.request.formData();
const id = parseInt(formData.get('id') as string);
@@ -310,7 +310,7 @@ export const actions: Actions = {
},
deleteDayPlan: async (event) => {
requireAdmin((await event.locals.auth())?.user?.id);
requireAdmin((await event.locals.auth())?.user);
const formData = await event.request.formData();
const id = parseInt(formData.get('id') as string);

View File

@@ -0,0 +1,597 @@
<script lang="ts">
import { base } from '$app/paths';
import { onMount } from 'svelte';
interface User {
id: string;
username: string;
full_name: string;
email: string | null;
auth_source: string;
has_local_credentials: boolean;
}
let users = $state<User[]>([]);
let loading = $state(true);
let error = $state('');
let editing = $state<User | null>(null);
let editUsername = $state('');
let editFullName = $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() {
loading = true;
try {
const res = await fetch(`${base}/admin/api/users`);
if (!res.ok) {
error = 'Failed to load users';
return;
}
users = await res.json();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load users';
} finally {
loading = false;
}
}
function startEdit(user: User) {
editing = user;
editUsername = user.username;
editFullName = user.full_name;
editEmail = user.email ?? '';
error = '';
}
function cancelEdit() {
editing = null;
editUsername = '';
editFullName = '';
editEmail = '';
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() {
if (!editing) return;
if (!editUsername.trim() || !editFullName.trim()) {
error = 'Username and full name are required';
return;
}
try {
const res = await fetch(`${base}/admin/api/users`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: editing.id,
username: editUsername.trim(),
full_name: editFullName.trim(),
email: editEmail.trim() || null
})
});
if (!res.ok) {
const data = await res.json();
error = data.error ?? 'Failed to update user';
return;
}
cancelEdit();
loadUsers();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update user';
}
}
async function removeUser(user: User) {
if (!confirm(`Remove ${user.username}?`)) return;
try {
const res = await fetch(`${base}/admin/api/users?id=${encodeURIComponent(user.id)}`, {
method: 'DELETE'
});
if (!res.ok) {
const data = await res.json();
alert(data.error ?? 'Failed to remove user');
return;
}
if (editing?.id === user.id) cancelEdit();
loadUsers();
} catch (e) {
alert(e instanceof Error ? e.message : 'Failed to remove user');
}
}
onMount(loadUsers);
</script>
<svelte:head>
<title>Users — Admin — Trips</title>
</svelte:head>
<div class="mx-auto max-w-4xl">
<div class="mb-6 flex items-start justify-between gap-4">
<div>
<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>
</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">
{#if error && !editing}
<div class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
{/if}
{#if editing}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-5">
<h3 class="mb-4 text-sm font-semibold text-gray-700">Edit user</h3>
{#if error}
<p class="mb-3 text-sm text-red-600">{error}</p>
{/if}
<div class="grid grid-cols-[1fr_1fr_1fr_auto] items-end gap-3">
<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={editUsername}
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={editFullName}
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"
bind:value={editEmail}
placeholder="name@example.com"
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 gap-2">
<button
onclick={submitEdit}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Save
</button>
<button
onclick={cancelEdit}
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
</div>
</div>
</div>
{/if}
<div class="overflow-hidden rounded-lg border border-gray-200">
{#if loading}
<p class="p-8 text-center text-sm text-gray-500">Loading...</p>
{:else if users.length === 0}
<p class="p-8 text-center text-sm text-gray-500">No users found.</p>
{:else}
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
>Username</th
>
<th
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
>Full name</th
>
<th
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
>Email</th
>
<th
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
>Auth source</th
>
<th
class="px-4 py-3 text-right text-xs font-semibold tracking-wide text-gray-500 uppercase"
>Actions</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each users as user (user.id)}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm font-medium text-gray-900">{user.username}</td>
<td class="px-4 py-3 text-sm text-gray-700">
{user.full_name || '—'}
</td>
<td class="px-4 py-3 text-sm text-gray-700">
{user.email || '—'}
</td>
<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"
>{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 class="px-4 py-3 text-right">
<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
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"
aria-label="Edit user"
title="Edit"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="h-4 w-4"
>
<path d="M12 20h9" />
<path
d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"
/>
</svg>
</button>
<button
onclick={() => removeUser(user)}
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-red-500 hover:bg-red-50 hover:text-red-600"
aria-label="Remove user"
title="Remove"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="h-4 w-4"
>
<path d="M3 6h18" />
<path d="M8 6V4h8v2" />
<path d="M19 6l-1 14H6L5 6" />
</svg>
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</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

@@ -1,8 +1,22 @@
import { env } from '$env/dynamic/private';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const resolveErrorMessage = (value: string | null): string | null => {
if (!value) return null;
if (value === 'CredentialsSignin') return 'Invalid credentials';
return null;
};
export const load: PageServerLoad = async ({ url }) => {
const authUrl = process.env.AUTH_URL ?? env.AUTH_URL ?? '';
const localAuthEnabled = (process.env.LOCAL_AUTH_ENABLED ?? env.LOCAL_AUTH_ENABLED) === 'true';
const appRoot = authUrl.replace(/\/auth$/, '');
return {
signinUrl: `${env.AUTH_URL}/signin/synology`
signinUrl: `${authUrl}/signin/synology`,
localSigninUrl: `${authUrl}/callback/local`,
callbackUrl: appRoot,
localAuthEnabled,
error: resolveErrorMessage(url.searchParams.get('error'))
};
};

View File

@@ -1,22 +1,86 @@
<script lang="ts">
import { onMount } from 'svelte';
let { data } = $props();
let form: HTMLFormElement;
onMount(() => {
form.submit();
});
</script>
<svelte:head>
<title>Sign in — Trips</title>
</svelte:head>
<form bind:this={form} method="POST" action={data.signinUrl} class="hidden">
<input type="hidden" name="csrfToken" />
</form>
<div class="mx-auto flex min-h-[70vh] w-full max-w-3xl flex-col justify-center px-6 py-12">
<div class="mb-10">
<h1 class="text-3xl font-semibold text-slate-900">Sign in</h1>
<p class="mt-2 text-sm text-slate-500">
Choose a sign-in method to access Trips.
</p>
</div>
<div class="flex min-h-[60vh] items-center justify-center">
<p class="text-gray-500">Redirecting to sign in…</p>
{#if data.error}
<div class="mb-6 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{data.error}
</div>
{/if}
<div class="grid gap-8 md:grid-cols-2">
<section class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 class="text-lg font-semibold text-slate-900">Synology account</h2>
<p class="mt-2 text-sm text-slate-500">
Use your Synology SSO account.
</p>
<form method="POST" action={data.signinUrl} class="mt-6">
<input type="hidden" name="csrfToken" />
<button
type="submit"
class="inline-flex w-full items-center justify-center rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-800"
>
Sign in with Synology
</button>
</form>
</section>
<section class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 class="text-lg font-semibold text-slate-900">Local account</h2>
<p class="mt-2 text-sm text-slate-500">
Sign in with your local username or email.
</p>
{#if data.localAuthEnabled}
<form method="POST" action={data.localSigninUrl} class="mt-6 space-y-4">
<input type="hidden" name="csrfToken" />
<input type="hidden" name="callbackUrl" value={data.callbackUrl} />
<div>
<label class="text-sm font-medium text-slate-700" for="identifier">
Username or email
</label>
<input
id="identifier"
name="identifier"
autocomplete="username"
class="mt-2 w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-slate-400 focus:outline-none"
required
/>
</div>
<div>
<label class="text-sm font-medium text-slate-700" for="password">Password</label>
<input
id="password"
name="password"
autocomplete="current-password"
type="password"
class="mt-2 w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-slate-400 focus:outline-none"
required
/>
</div>
<button
type="submit"
class="inline-flex w-full items-center justify-center rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-900 transition hover:border-slate-400"
>
Sign in locally
</button>
</form>
{:else}
<div class="mt-6 rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-500">
Local sign-in is disabled.
</div>
{/if}
</section>
</div>
</div>

View File

@@ -0,0 +1,38 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { load } from './+page.server';
beforeEach(() => {
process.env.AUTH_URL = 'https://example.com/auth';
});
describe('login page load', () => {
it('returns auth options when local auth is enabled', async () => {
process.env.LOCAL_AUTH_ENABLED = 'true';
const result = await load({
url: new URL('https://example.com/login')
} as Parameters<typeof load>[0]);
expect(result.signinUrl).toBe('https://example.com/auth/signin/synology');
expect(result.localSigninUrl).toBe('https://example.com/auth/callback/local');
expect(result.callbackUrl).toBe('https://example.com');
expect(result.localAuthEnabled).toBe(true);
});
it('maps credentials errors to a generic message', async () => {
process.env.LOCAL_AUTH_ENABLED = 'true';
const result = await load({
url: new URL('https://example.com/login?error=CredentialsSignin')
} as Parameters<typeof load>[0]);
expect(result.error).toBe('Invalid credentials');
});
it('disables local auth in the response when disabled', async () => {
process.env.LOCAL_AUTH_ENABLED = 'false';
const result = await load({
url: new URL('https://example.com/login')
} as Parameters<typeof load>[0]);
expect(result.localAuthEnabled).toBe(false);
});
});

15
svelte.config.js Normal file
View File

@@ -0,0 +1,15 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
paths: {
base: '/trips'
}
}
};
export default config;

View File

@@ -1,15 +0,0 @@
import adapter from "@sveltejs/adapter-node";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
import type { Config } from "@sveltejs/kit";
const config: Config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
paths: {
base: "/trips",
},
},
};
export default config;

View File

@@ -1,6 +1,7 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"types": ["bun-types"],
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,

View File

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