18 Commits

Author SHA1 Message Date
cbae428e26 docker) use debian user creation commands
All checks were successful
PR Checks / lint-test-and-docker-build (pull_request) Successful in 2m8s
2026-02-20 23:24:12 -05:00
e5c4cbc331 test) stabilize vitest db bootstrap in ci
Some checks failed
PR Checks / lint-test-and-docker-build (pull_request) Failing after 1m10s
2026-02-20 23:21:41 -05:00
e28741a4f1 trips) migrate sqlite and runtime to bun
Some checks failed
PR Checks / lint-test-and-docker-build (pull_request) Has been cancelled
2026-02-20 23:05:22 -05:00
982c84cc33 docker) build app with node instead of bun
All checks were successful
PR Checks / lint-test-and-docker-build (pull_request) Successful in 4m9s
2026-02-20 22:55:21 -05:00
82a242e362 docker) install native build tools in bun stage
Some checks failed
PR Checks / lint-test-and-docker-build (pull_request) Failing after 3m47s
2026-02-20 22:48:18 -05:00
d464e71654 ci) pin node 22 for svelte tooling
Some checks failed
PR Checks / lint-test-and-docker-build (pull_request) Failing after 2m39s
2026-02-20 22:42:35 -05:00
6e02e5128a ci) use js svelte config for runner compatibility
Some checks failed
PR Checks / lint-test-and-docker-build (pull_request) Failing after 1m53s
2026-02-20 22:39:02 -05:00
02b3d3ca1f test) sync svelte-kit before vitest
Some checks failed
PR Checks / lint-test-and-docker-build (pull_request) Failing after 2m0s
2026-02-20 22:24:33 -05:00
3455afe396 ci) run svelte-kit sync before tests
Some checks failed
PR Checks / lint-test-and-docker-build (pull_request) Failing after 1m42s
2026-02-20 22:19:54 -05:00
5331a05136 ci) switch linting to biome
Some checks failed
PR Checks / lint-test-and-docker-build (pull_request) Failing after 1m42s
2026-02-20 22:15:22 -05:00
2ff8a71b0c ci) use non-stylish eslint formatter in runner
Some checks failed
PR Checks / lint-test-and-docker-build (pull_request) Failing after 1m59s
2026-02-20 21:49:55 -05:00
dd9d142854 ci) revert to manual checkout in workflows
Some checks failed
PR Checks / lint-test-and-docker-build (pull_request) Failing after 6m16s
2026-02-20 20:53:08 -05:00
0e8ac03e07 ci) fixing checkout repo
Some checks failed
PR Checks / lint-test-and-docker-build (pull_request) Failing after 6s
2026-02-20 20:50:39 -05:00
8a0094a729 ci) use cloud domain for internal actions
Some checks failed
PR Checks / lint-test-and-docker-build (pull_request) Failing after 7s
2026-02-20 20:49:29 -05:00
b7c794db21 ci) use internal checkout action
Some checks failed
PR Checks / lint-test-and-docker-build (pull_request) Failing after 44s
2026-02-20 20:46:13 -05:00
19a62fce8d ci) use manual checkout for gitea actions
Some checks failed
PR Checks / lint-test-and-docker-build (pull_request) Failing after 6m23s
2026-02-20 20:41:12 -05:00
982f0d89be ci) fix checkout for gitea subpath
Some checks failed
PR Checks / lint-test-and-docker-build (pull_request) Failing after 33s
2026-02-20 20:37:19 -05:00
0f73be45cb ci) add gitea actions and refresh readme
Some checks failed
PR Checks / lint-test-and-docker-build (pull_request) Failing after 1m42s
2026-02-20 20:15:32 -05:00
76 changed files with 466 additions and 7548 deletions

View File

@@ -10,15 +10,6 @@ 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=

View File

@@ -1,21 +0,0 @@
# 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:/tmp/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,10 +4,9 @@ on:
push:
branches:
- main
workflow_dispatch:
env:
REGISTRY_HOST: registry.campbellwireless.net
REGISTRY_HOST: git.campbellwireless.net
IMAGE_NAME: ${{ github.repository }}
jobs:
@@ -51,107 +50,3 @@ 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

@@ -31,14 +31,6 @@
- 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,18 +28,6 @@ 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

@@ -43,19 +43,6 @@ 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
@@ -92,7 +79,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`, then calls the Portainer API to redeploy. It can also be run manually from the Actions UI.
- `main-image.yml`: builds and pushes a Docker image on push to `main`.
### Registry secrets for image publish
@@ -100,62 +87,10 @@ 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:
- `registry.campbellwireless.net/<owner>/<repo>:latest`
- `registry.campbellwireless.net/<owner>/<repo>:<short-sha>`
- `git.campbellwireless.net/<owner>/<repo>:latest`
- `git.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.

View File

@@ -6,19 +6,16 @@
"name": "trips",
"dependencies": {
"@auth/sveltekit": "^1.11.1",
"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",
"@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",
@@ -156,10 +153,6 @@
"@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=="],
@@ -322,8 +315,6 @@
"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=="],
@@ -362,8 +353,6 @@
"devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="],
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
@@ -520,10 +509,6 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"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=="],
@@ -546,10 +531,6 @@
"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=="],
@@ -684,8 +665,6 @@
"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

@@ -1,73 +0,0 @@
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

@@ -1,49 +0,0 @@
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/);
});

View File

@@ -1,25 +0,0 @@
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/);
});

View File

@@ -1,56 +0,0 @@
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

@@ -1,44 +0,0 @@
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

@@ -1,78 +0,0 @@
import { test, expect, type Page } 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';
function formatDate(offsetDays: number): string {
const date = new Date();
date.setDate(date.getDate() + offsetDays);
return date.toISOString().slice(0, 10);
}
async function addDestination(page: Page, cityQuery: string, arrivalDate?: 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();
if (arrivalDate) {
await dialog.getByLabel('Arrival').fill(arrivalDate);
}
await dialog.getByRole('button', { name: 'Add to trip' }).click();
await expect(dialog).toBeHidden();
}
test.beforeEach(async ({ context }) => {
await context.clearCookies();
});
test('dashboard surfaces map stops, stats, and trip status', async ({ page }) => {
const screenshotPath = test.info().outputPath('dashboard.png');
const suffix = uniqueSuffix();
const pastStart = formatDate(-18);
const pastEnd = formatDate(-15);
const currentStart = formatDate(-2);
const currentEnd = formatDate(4);
const futureStart = formatDate(28);
const futureEnd = formatDate(34);
const pastTripName = `E2E Past Trip ${suffix}`;
const currentTripName = `E2E Current Trip ${suffix}`;
const futureTripName = `E2E Future Trip ${suffix}`;
await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await createTrip(page, { name: pastTripName, startDate: pastStart, endDate: pastEnd });
await addDestination(page, 'London', pastStart);
await createTrip(page, { name: currentTripName, startDate: currentStart, endDate: currentEnd });
await addDestination(page, 'New York', currentStart);
await createTrip(page, { name: futureTripName, startDate: futureStart, endDate: futureEnd });
await addDestination(page, 'Tokyo', futureStart);
await page.goto('/trips/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
const map = page.getByTestId('trip-map');
await expect(map).toBeVisible();
await expect(map.locator('[data-testid="map-stop"][data-status="past"]')).toHaveCount(1);
await expect(map.locator('[data-testid="map-stop"][data-status="current"]')).toHaveCount(1);
await expect(map.locator('[data-testid="map-stop"][data-status="future"]')).toHaveCount(1);
await expect(page.getByRole('heading', { name: 'Trips in progress' })).toBeVisible();
await expect(page.getByText(currentTripName)).toBeVisible();
await expect(page.getByRole('heading', { name: 'Trips being planned' })).toBeVisible();
await expect(page.getByText(futureTripName)).toBeVisible();
await expect(page.getByTestId('stat-past-trips')).toHaveText('1');
await expect(page.getByTestId('stat-countries')).toHaveText('1');
await page.screenshot({ path: screenshotPath, fullPage: true });
});

View File

@@ -1,9 +0,0 @@
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();
});

View File

@@ -1,27 +0,0 @@
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();
}

View File

@@ -1,165 +0,0 @@
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; endDate?: string; description?: string }
): Promise<{ tripUrl: string; tripId: string }> {
await page.goto(NEW_TRIP_URL);
await expect(page.getByRole('heading', { name: 'Plan New Trip' })).toBeVisible();
const form = page.locator('main form').first();
if (values.startDate) {
await form.getByLabel('Start date').fill(values.startDate);
} else {
await form.getByRole('checkbox', { name: "I don't know yet" }).first().check();
}
if (values.endDate) {
await form.getByLabel('End date').fill(values.endDate);
}
if (values.description) {
await form.getByLabel('Description').fill(values.description);
}
await form.getByLabel('Trip name *').fill(values.name);
await form.evaluate((node) => {
(node as HTMLFormElement).requestSubmit();
});
await expect(page).toHaveURL(/\/trips\/trips\/(?!new$)[^/?#]+$/, { timeout: 30_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

@@ -1,79 +0,0 @@
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

@@ -1,60 +0,0 @@
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();
});

View File

@@ -1,20 +0,0 @@
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

@@ -1,17 +0,0 @@
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;

View File

@@ -1,90 +0,0 @@
// 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 });
function resolveDatabasePath(): string {
const rawUrl = process.env.DATABASE_URL;
if (rawUrl && rawUrl.startsWith('file:')) {
const rawPath = rawUrl.slice('file:'.length);
if (!rawPath) return resolve(process.cwd(), 'trips.test.db');
return rawPath.startsWith('/') ? rawPath : resolve(process.cwd(), rawPath);
}
return resolve(process.cwd(), 'trips.test.db');
}
const DB_PATH = resolveDatabasePath();
// 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);
}
}
console.log(`[e2e] Using test database at ${DB_PATH}`);
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();
}
};
console.log('[e2e] Running migrations...');
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
});
}
console.log('[e2e] Creating test users...');
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}`);

View File

@@ -1,15 +0,0 @@
// 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

@@ -1,79 +0,0 @@
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

@@ -1,51 +0,0 @@
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);
});

View File

@@ -1,70 +0,0 @@
import { test, expect } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js';
import { loginAsLocalUser, ensureSelfProfile } from './helpers/auth.js';
import { createTrip, openAddToTripMenuItem, 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);
}
async function addActivity(
page: import('@playwright/test').Page,
values: { name: string; startDate: string; startTime: string }
) {
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.getByLabel('Start date').fill(values.startDate);
await dialog.getByLabel('Start time', { exact: true }).fill(values.startTime);
await dialog.getByRole('button', { name: 'Add' }).click();
await expect(dialog).toBeHidden();
}
test.beforeEach(async ({ context }) => {
await context.clearCookies();
});
test('day and week views filter itinerary items', async ({ page }) => {
const suffix = uniqueSuffix();
const startDate = formatDate(0);
const endDate = formatDate(9);
const dayOneActivity = `Museum visit ${suffix}`;
const dayEightActivity = `Harbor walk ${suffix}`;
await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await ensureSelfProfile(page);
await createTrip(page, {
name: `E2E Date Views Trip ${suffix}`,
startDate,
endDate,
description: 'Date view coverage'
});
await addActivity(page, { name: dayOneActivity, startDate, startTime: '09:00' });
await addActivity(page, { name: dayEightActivity, startDate: formatDate(7), startTime: '11:00' });
await page.reload();
await expect(page.getByText(dayOneActivity)).toBeVisible();
await page.getByRole('button', { name: 'Day' }).click();
await expect(page.getByText('Day 1')).toBeVisible();
await expect(page.getByText(dayOneActivity)).toBeVisible();
await expect(page.getByText(dayEightActivity)).toBeHidden();
await page.getByRole('button', { name: 'Week' }).click();
await expect(page.getByText('Week 1')).toBeVisible();
await expect(page.getByText(dayOneActivity)).toBeVisible();
await expect(page.getByText(dayEightActivity)).toBeHidden();
await page.getByRole('button', { name: 'Next week' }).click();
await expect(page.getByText('Week 2')).toBeVisible();
await expect(page.getByText(dayEightActivity)).toBeVisible();
await expect(page.getByText(dayOneActivity)).toBeHidden();
});

View File

@@ -1,186 +0,0 @@
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

@@ -1,131 +0,0 @@
import { test, expect } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js';
import { loginAsLocalUser, ensureSelfProfile } from './helpers/auth.js';
import { addPackingList, createTrip, openAddToTripMenuItem, 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);
}
async function addDestination(
page: import('@playwright/test').Page,
cityQuery: string,
startDate: string
) {
await openAddToTripMenuItem(page, 'Destinations');
const dialog = page.getByRole('dialog', { name: 'Add destination' });
await expect(dialog).toBeVisible();
await dialog.getByLabel('City', { exact: false }).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 addActivity(
page: import('@playwright/test').Page,
values: { name: string; startDate: string; startTime: string }
) {
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.getByLabel('Start date').fill(values.startDate);
await dialog.getByLabel('Start time', { exact: true }).fill(values.startTime);
await dialog.getByRole('button', { name: 'Add' }).click();
await expect(dialog).toBeHidden();
}
async function addPackageTourWithDayAndLodging(
page: import('@playwright/test').Page,
values: { operatorName: string; tourName: string; startDate: string; dayTitle: string }
) {
await openAddToTripMenuItem(page, 'Package Tours');
const dialog = page.getByRole('dialog', { name: 'Add package tour' });
await expect(dialog).toBeVisible();
await dialog.getByLabel('Tour operator', { exact: false }).fill(values.operatorName);
await dialog.getByLabel('Tour name', { exact: false }).fill(values.tourName);
await dialog.locator('input[name="start_date"]').fill(values.startDate);
await dialog.getByRole('button', { name: 'Add tour' }).click();
await expect(dialog).toBeHidden();
await expect(page.getByText(values.operatorName)).toBeVisible();
await page.getByRole('button', { name: 'Add day' }).click();
const addDayForm = page.locator('form[action="?/addTourDay"]');
await expect(addDayForm).toBeVisible();
await addDayForm.getByPlaceholder(/Day title/i).fill(values.dayTitle);
await addDayForm.getByRole('button', { name: 'Add day' }).click();
const dayRow = page
.locator('div.mb-2.overflow-hidden.rounded-lg.border.border-gray-100')
.filter({ hasText: `Day 1` })
.filter({ hasText: values.dayTitle })
.first();
await expect(dayRow).toBeVisible();
}
test.beforeEach(async ({ context }) => {
await context.clearCookies();
});
test('timeline view shows scheduled and unscheduled plans with tour days', async ({ page }) => {
const suffix = uniqueSuffix();
const startDate = formatDate(10);
const activityDate = formatDate(11);
const destinationDate = formatDate(12);
const tourStartDate = formatDate(13);
const tripName = `E2E Timeline Trip ${suffix}`;
const activityName = `Waterfall hike ${suffix}`;
const destinationName = 'Tokyo';
const packingListName = `Timeline essentials ${suffix}`;
const tourOperator = `Trailblazer ${suffix}`;
const tourName = `Jungle Trek ${suffix}`;
const tourDayTitle = `Rainforest ${suffix}`;
await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await ensureSelfProfile(page);
await createTrip(page, {
name: tripName,
startDate,
description: 'Timeline view coverage'
});
await addDestination(page, destinationName, destinationDate);
await addActivity(page, { name: activityName, startDate: activityDate, startTime: '09:00' });
await addPackingList(page, { name: packingListName, items: ['Passport', 'Sunscreen'] });
await addPackageTourWithDayAndLodging(page, {
operatorName: tourOperator,
tourName,
startDate: tourStartDate,
dayTitle: tourDayTitle
});
await page.getByRole('button', { name: 'Timeline' }).click();
await expect(page.getByRole('button', { name: 'Timeline' })).toHaveAttribute(
'aria-pressed',
'true'
);
await expect(page.getByText(activityName)).toBeVisible();
await expect(page.getByText(destinationName, { exact: false })).toBeVisible();
await expect(page.getByText('09:00').first()).toBeVisible();
await expect(page.getByText(`Day 1 — ${tourDayTitle}`)).toBeVisible();
await expect(page.getByText('Unscheduled')).toBeVisible();
await expect(page.getByText(packingListName)).toBeVisible();
});

View File

@@ -14,22 +14,17 @@
"format": "prettier --write .",
"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"
"test:coverage": "vitest run --coverage"
},
"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",
"@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",
@@ -45,7 +40,6 @@
"vitest": "^4.0.18"
},
"dependencies": {
"@auth/sveltekit": "^1.11.1",
"argon2": "^0.41.1"
"@auth/sveltekit": "^1.11.1"
}
}

View File

@@ -1,59 +0,0 @@
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 });
const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_PATH;
const launchOptions = chromiumExecutablePath
? { executablePath: chromiumExecutablePath }
: undefined;
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',
...(launchOptions ? { launchOptions } : {})
},
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,8 +1,5 @@
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: [
@@ -21,59 +18,12 @@ 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, 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;
}
jwt({ token, profile }) {
if (profile?.sub) token.sub = profile.sub as string;
return token;
},
session({ session, token }) {

View File

@@ -42,15 +42,6 @@
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

@@ -1,66 +0,0 @@
<script lang="ts">
import { base } from '$app/paths';
interface Props {
operatorName: string;
tourName?: string | null;
highlightColor?: string | null;
size?: 'sm' | 'md';
showName?: boolean;
}
let {
operatorName,
tourName = null,
highlightColor = null,
size = 'sm',
showName = true
}: Props = $props();
function logoSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_|_$/g, '');
}
let logoVariant = $state<'square' | 'wide' | 'none'>('square');
const squareLogoUrl = $derived(
`${base}/package-tour-logos/${logoSlug(operatorName)}_square.svg`
);
const wideLogoUrl = $derived(
`${base}/package-tour-logos/${logoSlug(operatorName)}_wide.svg`
);
const logoUrl = $derived(
logoVariant === 'square' ? squareLogoUrl : logoVariant === 'wide' ? wideLogoUrl : null
);
const sizeClass = $derived(size === 'md' ? 'h-6 w-6' : 'h-5 w-5');
const textSizeClass = $derived(size === 'md' ? 'text-xs' : 'text-[10px]');
function handleLogoError() {
logoVariant = logoVariant === 'square' ? 'wide' : 'none';
}
</script>
<div class="flex items-center gap-2 text-xs text-gray-500">
{#if logoUrl}
<img
src={logoUrl}
alt={operatorName}
class="{sizeClass} rounded-md object-contain"
on:error={handleLogoError}
/>
{:else}
<span
class="{sizeClass} {textSizeClass} flex items-center justify-center rounded-md border border-gray-200 font-semibold"
style="background-color: {highlightColor ?? '#F3F4F6'}; color: {highlightColor ? '#111827' : '#6B7280'}"
>
{operatorName.slice(0, 1).toUpperCase()}
</span>
{/if}
{#if showName}
<span class="truncate">{operatorName}{tourName ? `: ${tourName}` : ''}</span>
{/if}
</div>

View File

@@ -1,109 +0,0 @@
<script lang="ts">
import TourBadge from '$lib/components/TourBadge.svelte';
import type { Plan } from '$lib/server/plans.js';
import type { PackageTour } from '$lib/server/package-tours.js';
interface Props {
plan: Plan;
tour: PackageTour & { highlight_color: string | null };
onEdit?: () => void;
onDelete?: () => void;
}
let { plan, tour, onEdit, onDelete }: Props = $props();
function formatDate(d: string | null): string {
if (!d) return '';
return new Date(d + 'T00:00:00').toLocaleDateString(undefined, {
day: 'numeric',
month: 'short',
year: 'numeric'
});
}
function formatTime(t: string | null): string {
if (!t) return '';
return t.slice(0, 5);
}
const statusConfig = {
idea: { label: 'Idea', class: 'bg-gray-100 text-gray-600' },
tentative: { label: 'Tentative', class: 'bg-yellow-100 text-yellow-800' },
confirmed: { label: 'Confirmed', class: 'bg-green-100 text-green-800' }
};
const hasStart = $derived(tour.start_date || tour.start_time);
const hasEnd = $derived(tour.end_date || tour.end_time);
</script>
<div
class="rounded-xl border-2 bg-white p-4 shadow-sm"
style="border-color: {tour.highlight_color ?? '#E5E7EB'}"
>
<div class="flex items-start justify-between gap-3">
<TourBadge
operatorName={tour.operator_name}
tourName={tour.tour_name}
highlightColor={tour.highlight_color}
size="md"
/>
<div class="flex shrink-0 items-center gap-1">
<span
class="rounded-full px-2.5 py-0.5 text-xs font-medium {statusConfig[plan.status].class}"
>
{statusConfig[plan.status].label}
</span>
{#if onEdit}
<button
type="button"
onclick={onEdit}
class="rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="Edit tour"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
{/if}
{#if onDelete}
<button
type="button"
onclick={onDelete}
class="rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-red-600"
aria-label="Remove tour"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
{/if}
</div>
</div>
{#if hasStart || hasEnd}
<div class="mt-3 grid grid-cols-2 gap-4 border-t border-gray-100 pt-3">
<div>
<p class="text-xs font-medium tracking-wide text-gray-400 uppercase">Start</p>
<p class="mt-0.5 text-sm text-gray-900">
{#if tour.start_date}
{formatDate(tour.start_date)} {formatTime(tour.start_time)}
{:else}
{formatTime(tour.start_time)}
{/if}
</p>
</div>
<div>
<p class="text-xs font-medium tracking-wide text-gray-400 uppercase">End</p>
<p class="mt-0.5 text-sm text-gray-900">
{#if tour.end_date}
{formatDate(tour.end_date)} {formatTime(tour.end_time)}
{:else}
{formatTime(tour.end_time)}
{/if}
</p>
</div>
</div>
{/if}
</div>

View File

@@ -4,36 +4,28 @@
import type { Trip } from '$lib/server/trips.js';
interface Props {
trips?: Trip[];
title?: string;
emptyMessage?: string;
trips: Trip[];
title: string;
emptyMessage: string;
}
let { trips = [], title = 'Trips', emptyMessage = 'No trips yet.' }: Props = $props();
const tripList = $derived(trips ?? []);
let { trips, title, emptyMessage }: Props = $props();
let view = $state<'tile' | 'table'>('tile');
</script>
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">{title}</h1>
{#if tripList.length > 0}
{#if trips.length > 0}
<div class="flex items-center gap-1 rounded-md border border-gray-200 bg-gray-50 p-1">
<button
onclick={() => (view = 'tile')}
onclick={() => view = 'tile'}
title="Tile view"
class="rounded p-1.5 transition-colors"
style={view === 'tile' ? 'background:#fff; box-shadow:0 1px 2px rgba(0,0,0,0.08);' : ''}
>
<!-- Grid icon -->
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke={view === 'tile' ? '#111827' : '#9ca3af'}
stroke-width="2"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={view === 'tile' ? '#111827' : '#9ca3af'} stroke-width="2">
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
@@ -41,20 +33,13 @@
</svg>
</button>
<button
onclick={() => (view = 'table')}
onclick={() => view = 'table'}
title="Table view"
class="rounded p-1.5 transition-colors"
style={view === 'table' ? 'background:#fff; box-shadow:0 1px 2px rgba(0,0,0,0.08);' : ''}
>
<!-- List icon -->
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke={view === 'table' ? '#111827' : '#9ca3af'}
stroke-width="2"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={view === 'table' ? '#111827' : '#9ca3af'} stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
@@ -64,14 +49,14 @@
{/if}
</div>
{#if tripList.length === 0}
{#if trips.length === 0}
<p class="text-gray-500">{emptyMessage}</p>
{:else if view === 'tile'}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each tripList as trip}
{#each trips as trip}
<TripTile {trip} />
{/each}
</div>
{:else}
<TripTable trips={tripList} />
<TripTable {trips} />
{/if}

View File

@@ -1,34 +0,0 @@
import { describe, it, expect } from 'vitest';
import { addDaysToDate, buildDateSequence, buildWeekRanges } from './date-views.js';
describe('date-views', () => {
it('adds days to a date string', () => {
expect(addDaysToDate('2026-04-01', 1)).toBe('2026-04-02');
expect(addDaysToDate('2026-04-01', 7)).toBe('2026-04-08');
});
it('builds an inclusive date sequence', () => {
const dates = buildDateSequence('2026-04-01', '2026-04-03');
expect(dates).toEqual(['2026-04-01', '2026-04-02', '2026-04-03']);
});
it('returns empty sequence when end is before start', () => {
expect(buildDateSequence('2026-04-03', '2026-04-01')).toEqual([]);
});
it('chunks weeks into seven-day ranges', () => {
const dates = buildDateSequence('2026-04-01', '2026-04-10');
const ranges = buildWeekRanges(dates);
expect(ranges).toHaveLength(2);
expect(ranges[0]).toEqual({
start: '2026-04-01',
end: '2026-04-07',
weekNumber: 1
});
expect(ranges[1]).toEqual({
start: '2026-04-08',
end: '2026-04-10',
weekNumber: 2
});
});
});

View File

@@ -1,33 +0,0 @@
export function addDaysToDate(date: string, offset: number): string {
const [year, month, day] = date.split('-').map(Number);
const ts = Date.UTC(year, month - 1, day + offset);
return new Date(ts).toISOString().slice(0, 10);
}
export function buildDateSequence(start: string, end: string): string[] {
if (!start || !end) return [];
if (start > end) return [];
const dates: string[] = [];
let current = start;
while (current <= end) {
dates.push(current);
current = addDaysToDate(current, 1);
}
return dates;
}
export function buildWeekRanges(
dates: string[]
): Array<{ start: string; end: string; weekNumber: number }> {
const ranges: Array<{ start: string; end: string; weekNumber: number }> = [];
for (let i = 0; i < dates.length; i += 7) {
const slice = dates.slice(i, i + 7);
if (slice.length === 0) continue;
ranges.push({
start: slice[0],
end: slice[slice.length - 1],
weekNumber: Math.floor(i / 7) + 1
});
}
return ranges;
}

View File

@@ -1,81 +0,0 @@
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,73 +1,12 @@
import { env } from '$env/dynamic/private';
import { db } from '$lib/server/db/index.js';
const getAdminIdentifiers = (): string[] =>
(process.env.ADMIN_USER_IDS ?? env.ADMIN_USER_IDS ?? '')
export function requireAdmin(userId: string | undefined): void {
if (!userId) throw new Error('Not authenticated');
const adminIds = (env.ADMIN_USER_IDS ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
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)) {
if (adminIds.length === 0 || !adminIds.includes(userId)) {
throw new Error('Admin access required');
}
}

View File

@@ -1,192 +0,0 @@
import { db } from './db/index.js';
import type { Trip } from './trips.js';
export type TripStatus = 'past' | 'in_progress' | 'planned';
export type MapStopStatus = 'past' | 'current' | 'future';
export interface DashboardTripSummary extends Trip {
status: TripStatus;
planCount: number;
}
export interface DashboardMapStop {
tripId: string;
tripName: string;
label: string;
country: string | null;
countryCode: string;
lat: number;
lon: number;
status: MapStopStatus;
}
export interface DashboardStats {
pastTrips: number;
daysAway: number;
countriesVisited: number;
citiesVisited: number;
topCountries: Array<{ country: string; countryCode: string; count: number }>;
}
export interface DashboardData {
trips: DashboardTripSummary[];
inProgressTrips: DashboardTripSummary[];
plannedTrips: DashboardTripSummary[];
mapStops: DashboardMapStop[];
stats: DashboardStats;
}
function isValidDate(value: string | null): value is string {
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
}
function toDate(value: string | null): Date | null {
if (!isValidDate(value)) return null;
const parsed = new Date(`${value}T00:00:00Z`);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function dayDiffInclusive(start: string | null, end: string | null): number {
const startDate = toDate(start);
const endDate = toDate(end);
if (!startDate || !endDate) return 0;
const diffMs = endDate.getTime() - startDate.getTime();
if (Number.isNaN(diffMs)) return 0;
return Math.max(0, Math.round(diffMs / 86400000) + 1);
}
function getTripStatus(trip: Trip, today: string): TripStatus {
if (trip.end_date && trip.end_date < today) return 'past';
if (trip.start_date && trip.start_date <= today) return 'in_progress';
return 'planned';
}
export function getDashboardData(userId: string): DashboardData {
const today = new Date().toISOString().slice(0, 10);
const trips = db.all<Trip>(
`SELECT * FROM trips WHERE user_id = ? ORDER BY COALESCE(start_date, created_at) ASC`,
[userId]
);
const planCounts = db.all<{ trip_id: string; count: number }>(
`SELECT trip_id, COUNT(*) as count FROM plans WHERE user_id = ? GROUP BY trip_id`,
[userId]
);
const planCountByTripId = new Map(planCounts.map((row) => [row.trip_id, row.count]));
const summaryTrips: DashboardTripSummary[] = trips.map((trip) => ({
...trip,
status: getTripStatus(trip, today),
planCount: planCountByTripId.get(trip.id) ?? 0
}));
const inProgressTrips = summaryTrips.filter((trip) => trip.status === 'in_progress');
const plannedTrips = summaryTrips.filter((trip) => trip.status === 'planned');
const pastTrips = summaryTrips.filter((trip) => trip.status === 'past');
const mapStopRows = db.all<{
trip_id: string;
trip_name: string;
label: string | null;
country: string | null;
country_code: string | null;
lat: number | null;
lon: number | null;
}>(
`SELECT
p.trip_id as trip_id,
MAX(t.name) as trip_name,
COALESCE(MAX(p.city_name), MAX(p.country)) as label,
MAX(p.country) as country,
p.country_code as country_code,
c.lat as lat,
c.lon as lon
FROM plans p
JOIN trips t ON t.id = p.trip_id
JOIN (
SELECT country_code, AVG(latitude) as lat, AVG(longitude) as lon
FROM airports
WHERE latitude IS NOT NULL AND longitude IS NOT NULL
GROUP BY country_code
) c ON c.country_code = p.country_code
WHERE p.user_id = ?
AND p.type = 'destination'
AND p.country_code IS NOT NULL
AND TRIM(p.country_code) != ''
GROUP BY p.trip_id, p.country_code
ORDER BY MIN(p.created_at) ASC`,
[userId]
);
const statusByTripId = new Map(summaryTrips.map((trip) => [trip.id, trip.status]));
const mapStops: DashboardMapStop[] = mapStopRows
.filter((row) => row.country_code && row.lat !== null && row.lon !== null)
.map((row) => {
const status = statusByTripId.get(row.trip_id) ?? 'planned';
const mapStatus: MapStopStatus =
status === 'past' ? 'past' : status === 'in_progress' ? 'current' : 'future';
return {
tripId: row.trip_id,
tripName: row.trip_name ?? 'Trip',
label: row.label ?? row.country ?? 'Destination',
country: row.country,
countryCode: row.country_code ?? 'XX',
lat: row.lat ?? 0,
lon: row.lon ?? 0,
status: mapStatus
};
});
const destinationRows = db.all<{
country: string | null;
country_code: string | null;
city_name: string | null;
}>(
`SELECT p.country, p.country_code, p.city_name
FROM plans p
JOIN trips t ON t.id = p.trip_id
WHERE p.user_id = ?
AND t.user_id = ?
AND t.end_date IS NOT NULL
AND t.end_date < ?
AND p.type = 'destination'`,
[userId, userId, today]
);
const countryCounts = new Map<string, { country: string; countryCode: string; count: number }>();
const cities = new Set<string>();
for (const row of destinationRows) {
if (row.city_name) cities.add(row.city_name);
if (!row.country_code) continue;
const existing = countryCounts.get(row.country_code) ?? {
country: row.country ?? row.country_code,
countryCode: row.country_code,
count: 0
};
existing.count += 1;
countryCounts.set(row.country_code, existing);
}
const topCountries = Array.from(countryCounts.values())
.sort((a, b) => b.count - a.count || a.country.localeCompare(b.country))
.slice(0, 5);
const daysAway = pastTrips.reduce(
(total, trip) => total + dayDiffInclusive(trip.start_date, trip.end_date),
0
);
return {
trips: summaryTrips,
inProgressTrips,
plannedTrips,
mapStops,
stats: {
pastTrips: pastTrips.length,
daysAway,
countriesVisited: countryCounts.size,
citiesVisited: cities.size,
topCountries
}
};
}

View File

@@ -53,27 +53,6 @@ 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,
@@ -600,21 +579,14 @@ function seedCities(db: Database): void {
const cities: { name: string; country: string; country_code: string; population: number }[] =
JSON.parse(data);
let id = 1;
db.run('BEGIN');
for (const city of cities) {
db.run(
'INSERT OR IGNORE INTO cities (id, name, country, country_code, population) VALUES (?, ?, ?, ?, ?)',
[id++, city.name, city.country, city.country_code, city.population]
);
}
db.run('COMMIT');
console.log(`[db] Seeded ${cities.length} cities`);
} catch (e) {
try {
db.run('ROLLBACK');
} catch {
/* ignore rollback failure */
}
console.error('[db] Failed to seed cities:', e);
}
}
@@ -629,7 +601,6 @@ function seedAirports(db: Database): void {
const lines = data.split('\n').filter((line) => line.trim());
let imported = 0;
let skipped = 0;
db.run('BEGIN');
for (const line of lines) {
// Skip comments
@@ -684,15 +655,9 @@ function seedAirports(db: Database): void {
skipped++;
}
}
db.run('COMMIT');
console.log(`[db] Seeded ${imported} airports from airports.dat (${skipped} skipped)`);
return;
} catch (_fileError) {
try {
db.run('ROLLBACK');
} catch {
/* ignore rollback failure */
}
// File doesn't exist, fall back to minimal seed only if table is empty
const airportsRow = db.get<{ count: number }>('SELECT COUNT(*) as count FROM airports');
if ((airportsRow?.count ?? 0) === 0) {
@@ -796,7 +761,6 @@ function seedAirports(db: Database): void {
];
let id = 1;
db.run('BEGIN');
for (const airport of majorAirports) {
db.run(
`INSERT OR IGNORE INTO airports (id, iata_code, icao_code, name, city, country, country_code, latitude, longitude, timezone)
@@ -815,14 +779,8 @@ function seedAirports(db: Database): void {
]
);
}
db.run('COMMIT');
console.log(`[db] Seeded ${majorAirports.length} airports`);
} catch (e) {
try {
db.run('ROLLBACK');
} catch {
/* ignore rollback failure */
}
console.error('[db] Failed to seed airports:', e);
}
}
@@ -837,7 +795,6 @@ function seedAirlines(db: Database): void {
const lines = data.split('\n').filter((line) => line.trim());
let imported = 0;
let skipped = 0;
db.run('BEGIN');
for (const line of lines) {
// Skip comments
@@ -879,15 +836,9 @@ function seedAirlines(db: Database): void {
skipped++;
}
}
db.run('COMMIT');
console.log(`[db] Seeded ${imported} airlines from airlines.dat (${skipped} skipped)`);
return;
} catch (_fileError) {
try {
db.run('ROLLBACK');
} catch {
/* ignore rollback failure */
}
// File doesn't exist, fall back to minimal seed only if table is empty
const airlinesRow = db.get<{ count: number }>('SELECT COUNT(*) as count FROM airlines');
if ((airlinesRow?.count ?? 0) === 0) {
@@ -949,7 +900,6 @@ function seedAirlines(db: Database): void {
];
let id = 1;
db.run('BEGIN');
for (const airline of majorAirlines) {
db.run(
`INSERT OR IGNORE INTO airlines (id, iata_code, icao_code, name, country, country_code)
@@ -957,14 +907,8 @@ function seedAirlines(db: Database): void {
[id++, airline.iata, airline.icao, airline.name, airline.country, airline.country_code]
);
}
db.run('COMMIT');
console.log(`[db] Seeded ${majorAirlines.length} airlines`);
} catch (e) {
try {
db.run('ROLLBACK');
} catch {
/* ignore rollback failure */
}
console.error('[db] Failed to seed airlines:', e);
}
}

View File

@@ -1,84 +0,0 @@
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

@@ -1,154 +0,0 @@
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

@@ -1,67 +0,0 @@
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,6 +1,5 @@
import { db } from './db/index.js';
import { randomUUID } from 'crypto';
import { upsertUserProfile } from './users.js';
export interface Person {
id: string;
@@ -34,7 +33,6 @@ 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();
@@ -43,7 +41,6 @@ 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

@@ -1,151 +0,0 @@
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();
});
});

View File

@@ -1,195 +0,0 @@
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,295 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { buildTripTimeline } from './timeline.js';
import type { Plan } from './server/plans.js';
import type { PackageTour, TourDay } from './server/package-tours.js';
let planCounter = 0;
function makePlan(overrides: Partial<Plan>): Plan {
planCounter += 1;
return {
id: overrides.id ?? `plan-${planCounter}`,
trip_id: 'trip-1',
user_id: 'user-1',
type: overrides.type ?? 'destination',
status: overrides.status ?? 'idea',
parent_id: overrides.parent_id ?? null,
title: overrides.title ?? 'Plan',
notes: overrides.notes ?? null,
city_id: overrides.city_id ?? null,
city_name: overrides.city_name ?? null,
country: overrides.country ?? null,
country_code: overrides.country_code ?? null,
start_date: overrides.start_date ?? null,
end_date: overrides.end_date ?? null,
position: overrides.position ?? 0,
created_at: overrides.created_at ?? '2026-01-01T00:00:00Z',
updated_at: overrides.updated_at ?? '2026-01-01T00:00:00Z'
};
}
function makeTour(overrides: Partial<PackageTour>): PackageTour {
return {
id: overrides.id ?? 'tour-1',
plan_id: overrides.plan_id ?? 'tour-plan-1',
operator_name: overrides.operator_name ?? 'Adventure Co',
tour_name: overrides.tour_name ?? 'Rainforest',
confirmation_number: overrides.confirmation_number ?? null,
start_date: overrides.start_date ?? null,
start_time: overrides.start_time ?? null,
start_timezone: overrides.start_timezone ?? null,
end_date: overrides.end_date ?? null,
end_time: overrides.end_time ?? null,
end_timezone: overrides.end_timezone ?? null,
price: overrides.price ?? null,
currency: overrides.currency ?? 'USD',
created_at: overrides.created_at ?? '2026-01-01T00:00:00Z',
updated_at: overrides.updated_at ?? '2026-01-01T00:00:00Z'
};
}
function makeTourDay(overrides: Partial<TourDay>): TourDay {
return {
plan_id: overrides.plan_id ?? 'day-plan-1',
day_number: overrides.day_number ?? 1,
title: overrides.title ?? null,
notes: overrides.notes ?? null,
childPlans: overrides.childPlans ?? []
};
}
describe('buildTripTimeline', () => {
beforeEach(() => {
planCounter = 0;
});
it('orders scheduled plans by date and time', () => {
const early = makePlan({ type: 'activity', start_date: '2026-05-01' });
const later = makePlan({ type: 'activity', start_date: '2026-05-01' });
const nextDay = makePlan({ type: 'destination', start_date: '2026-05-02' });
const timeline = buildTripTimeline({
plans: [nextDay, later, early],
flightBookings: [],
privateVehicles: [],
otherTransports: [],
lodgings: [],
activities: [
{
plan_id: early.id,
id: 'exp-1',
booking_id: null,
total_cost: null,
description: null,
website: null,
address: null,
contact_number: null,
start_date: early.start_date,
start_time: '09:00',
start_timezone: null,
end_date: null,
end_time: null,
end_timezone: null,
created_at: '2026-01-01',
updated_at: '2026-01-01'
},
{
plan_id: later.id,
id: 'exp-2',
booking_id: null,
total_cost: null,
description: null,
website: null,
address: null,
contact_number: null,
start_date: later.start_date,
start_time: '10:30',
start_timezone: null,
end_date: null,
end_time: null,
end_timezone: null,
created_at: '2026-01-01',
updated_at: '2026-01-01'
}
],
restaurants: [],
packingLists: [],
todos: [],
packageTours: []
});
expect(timeline.scheduled).toHaveLength(2);
expect(timeline.scheduled[0].date).toBe('2026-05-01');
const ids = timeline.scheduled[0].entries
.filter((entry) => entry.kind === 'plan')
.map((entry) => (entry.kind === 'plan' ? entry.plan.id : ''));
expect(ids).toEqual([early.id, later.id]);
expect(timeline.scheduled[1].date).toBe('2026-05-02');
});
it('maps tour day items to tour start date offsets', () => {
const tourPlan = makePlan({ id: 'tour-plan-1', type: 'tour' });
const day1 = makeTourDay({ plan_id: 'day-1', day_number: 1, title: 'Arrival' });
const day2 = makeTourDay({ plan_id: 'day-2', day_number: 2, title: 'Hike' });
const activityPlan = makePlan({ id: 'activity-1', type: 'activity', parent_id: day2.plan_id });
const tour = makeTour({ plan_id: tourPlan.id, start_date: '2027-01-01' });
const timeline = buildTripTimeline({
plans: [tourPlan, activityPlan],
flightBookings: [],
privateVehicles: [],
otherTransports: [],
lodgings: [],
activities: [
{
plan_id: activityPlan.id,
id: 'exp-3',
booking_id: null,
total_cost: null,
description: null,
website: null,
address: null,
contact_number: null,
start_date: null,
start_time: null,
start_timezone: null,
end_date: null,
end_time: null,
end_timezone: null,
created_at: '2026-01-01',
updated_at: '2026-01-01'
}
],
restaurants: [],
packingLists: [],
todos: [],
packageTours: [
{ ...tour, days: [day1, day2], ungroupedChildPlans: [], highlight_color: '#FFAA00' }
]
});
const day2Group = timeline.scheduled.find((group) => group.date === '2027-01-02');
expect(day2Group).toBeTruthy();
const dayEntries = day2Group?.entries.filter((entry) => entry.kind === 'day') ?? [];
expect(dayEntries).toHaveLength(1);
const planEntries = day2Group?.entries.filter((entry) => entry.kind === 'plan') ?? [];
expect(planEntries).toHaveLength(1);
expect(planEntries[0].kind === 'plan' ? planEntries[0].tourDay?.dayNumber : null).toBe(2);
});
it('puts tour days without a start date into unscheduled', () => {
const tourPlan = makePlan({ id: 'tour-plan-2', type: 'tour' });
const day1 = makeTourDay({ plan_id: 'day-3', day_number: 1, title: 'Intro' });
const activityPlan = makePlan({ id: 'activity-2', type: 'activity', parent_id: day1.plan_id });
const tour = makeTour({ id: 'tour-2', plan_id: tourPlan.id, start_date: null });
const timeline = buildTripTimeline({
plans: [tourPlan, activityPlan],
flightBookings: [],
privateVehicles: [],
otherTransports: [],
lodgings: [],
activities: [
{
plan_id: activityPlan.id,
id: 'exp-4',
booking_id: null,
total_cost: null,
description: null,
website: null,
address: null,
contact_number: null,
start_date: null,
start_time: null,
start_timezone: null,
end_date: null,
end_time: null,
end_timezone: null,
created_at: '2026-01-01',
updated_at: '2026-01-01'
}
],
restaurants: [],
packingLists: [],
todos: [],
packageTours: [{ ...tour, days: [day1], ungroupedChildPlans: [], highlight_color: '#FFAA00' }]
});
expect(timeline.scheduled).toHaveLength(0);
expect(timeline.unscheduled.some((entry) => entry.kind === 'day')).toBe(true);
expect(
timeline.unscheduled.some(
(entry) => entry.kind === 'plan' && entry.plan.id === activityPlan.id
)
).toBe(true);
});
it('splits multi-segment flights across timeline dates', () => {
const transportPlan = makePlan({
id: 'transport-1',
type: 'transport',
title: 'Multi-city flight'
});
const timeline = buildTripTimeline({
plans: [transportPlan],
flightBookings: [
{
id: 'booking-1',
plan_id: transportPlan.id,
confirmation_number: null,
price: null,
currency: 'USD',
created_at: '2026-01-01',
updated_at: '2026-01-01',
segments: [
{
id: 'seg-1',
booking_id: 'booking-1',
position: 0,
departure_date: '2026-07-10',
airline_id: null,
airline_iata: 'UA',
airline_name: 'United',
flight_number: '100',
created_at: '2026-01-01',
updated_at: '2026-01-01',
route: null
},
{
id: 'seg-2',
booking_id: 'booking-1',
position: 1,
departure_date: '2026-07-12',
airline_id: null,
airline_iata: 'UA',
airline_name: 'United',
flight_number: '200',
created_at: '2026-01-01',
updated_at: '2026-01-01',
route: null
}
]
}
],
privateVehicles: [],
otherTransports: [],
lodgings: [],
activities: [],
restaurants: [],
packingLists: [],
todos: [],
packageTours: []
});
expect(timeline.scheduled).toHaveLength(2);
expect(timeline.scheduled[0].date).toBe('2026-07-10');
expect(timeline.scheduled[1].date).toBe('2026-07-12');
const firstEntry = timeline.scheduled[0].entries[0];
const secondEntry = timeline.scheduled[1].entries[0];
expect(firstEntry.kind === 'plan' ? firstEntry.flightSegmentIndex : null).toBe(0);
expect(secondEntry.kind === 'plan' ? secondEntry.flightSegmentIndex : null).toBe(1);
expect(firstEntry.id).not.toBe(secondEntry.id);
});
});

View File

@@ -1,321 +0,0 @@
import type { Plan } from '$lib/server/plans.js';
import type { FlightBooking } from '$lib/server/flights.js';
import type { PrivateVehicleTransport } from '$lib/server/private-vehicles.js';
import type { OtherTransport } from '$lib/server/other-transports.js';
import type { Lodging } from '$lib/server/lodgings.js';
import type { ExperiencePlan } from '$lib/server/experiences.js';
import type { ChecklistWithItems } from '$lib/server/checklists.js';
import type { PackageTour, TourDay, ChildPlanSummary } from '$lib/server/package-tours.js';
export interface TripTimelineInput {
plans: Plan[];
flightBookings: Array<
FlightBooking & {
segments: Array<{
departure_date: string;
route?: { departure_datetime?: string | null } | null;
}>;
}
>;
privateVehicles: PrivateVehicleTransport[];
otherTransports: OtherTransport[];
lodgings: Array<Lodging & { planStatus?: string }>;
activities: ExperiencePlan[];
restaurants: ExperiencePlan[];
packingLists: ChecklistWithItems[];
todos: ChecklistWithItems[];
packageTours: Array<
PackageTour & {
days: TourDay[];
ungroupedChildPlans: ChildPlanSummary[];
highlight_color: string | null;
}
>;
}
export interface TourContext {
planId: string;
tourId: string;
operatorName: string;
tourName: string | null;
startDate: string | null;
startTime: string | null;
highlightColor: string | null;
}
export interface TourDayContext {
planId: string;
dayNumber: number;
title: string | null;
}
export type TimelineEntry =
| {
kind: 'day';
id: string;
date: string | null;
tour: TourContext;
day: TourDayContext;
sortRank: number;
sortTime: string;
sequence: number;
}
| {
kind: 'plan';
id: string;
date: string | null;
time: string | null;
plan: Plan;
flightSegmentIndex: number | null;
tour: TourContext | null;
tourDay: TourDayContext | null;
sortRank: number;
sortTime: string;
sequence: number;
};
export interface TimelineGroup {
date: string;
entries: TimelineEntry[];
}
export interface TripTimeline {
scheduled: TimelineGroup[];
unscheduled: TimelineEntry[];
}
function addDaysToDate(date: string, offset: number): string {
const [year, month, day] = date.split('-').map(Number);
const ts = Date.UTC(year, month - 1, day + offset);
return new Date(ts).toISOString().slice(0, 10);
}
function normalizeTime(value: string | null | undefined): string | null {
if (!value) return null;
return value.slice(0, 5);
}
function splitDateTime(value: string | null | undefined): {
date: string | null;
time: string | null;
} {
if (!value) return { date: null, time: null };
const [date, time] = value.split('T');
return { date: date ?? null, time: normalizeTime(time) };
}
export function buildTripTimeline(input: TripTimelineInput): TripTimeline {
const flightByPlanId = new Map(input.flightBookings.map((booking) => [booking.plan_id, booking]));
const privateVehicleByPlanId = new Map(
input.privateVehicles.map((vehicle) => [vehicle.plan_id, vehicle])
);
const otherTransportByPlanId = new Map(
input.otherTransports.map((transport) => [transport.plan_id, transport])
);
const lodgingByPlanId = new Map(input.lodgings.map((lodging) => [lodging.plan_id, lodging]));
const experienceByPlanId = new Map(
[...input.activities, ...input.restaurants].map((experience) => [
experience.plan_id,
experience
])
);
const tourByPlanId = new Map(
input.packageTours.map((tour) => [
tour.plan_id,
{
planId: tour.plan_id,
tourId: tour.id,
operatorName: tour.operator_name,
tourName: tour.tour_name ?? null,
startDate: tour.start_date ?? null,
startTime: tour.start_time ?? null,
highlightColor: tour.highlight_color ?? null
} satisfies TourContext
])
);
const dayByPlanId = new Map<string, { day: TourDayContext; tour: TourContext }>();
for (const tour of input.packageTours) {
const tourContext = tourByPlanId.get(tour.plan_id);
if (!tourContext) continue;
for (const day of tour.days) {
dayByPlanId.set(day.plan_id, {
day: { planId: day.plan_id, dayNumber: day.day_number, title: day.title ?? null },
tour: tourContext
});
}
}
const entries: TimelineEntry[] = [];
let sequence = 0;
for (const plan of input.plans) {
if (plan.type === 'day') continue;
const dayContext = plan.parent_id ? dayByPlanId.get(plan.parent_id) : null;
const tourContext =
dayContext?.tour ??
(plan.parent_id ? (tourByPlanId.get(plan.parent_id) ?? null) : null) ??
tourByPlanId.get(plan.id) ??
null;
let date: string | null = null;
let time: string | null = null;
switch (plan.type) {
case 'destination':
date = plan.start_date ?? plan.end_date ?? null;
break;
case 'transport': {
const flight = flightByPlanId.get(plan.id);
if (flight?.segments?.length) {
for (let segmentIndex = 0; segmentIndex < flight.segments.length; segmentIndex += 1) {
const segment = flight.segments[segmentIndex];
const routeDateTime = splitDateTime(segment.route?.departure_datetime ?? null);
let segmentDate = routeDateTime.date ?? segment.departure_date ?? null;
const segmentTime = routeDateTime.time;
if (dayContext?.tour.startDate) {
segmentDate = addDaysToDate(dayContext.tour.startDate, dayContext.day.dayNumber - 1);
}
const sortTime = segmentTime ?? '24:00';
entries.push({
kind: 'plan',
id: `${plan.id}-segment-${segmentIndex}`,
date: segmentDate,
time: segmentTime,
plan,
flightSegmentIndex: segmentIndex,
tour: tourContext,
tourDay: dayContext?.day ?? null,
sortRank: 1,
sortTime,
sequence: sequence++
});
}
continue;
}
const privateVehicle = privateVehicleByPlanId.get(plan.id);
if (privateVehicle) {
date = privateVehicle.departure_date ?? privateVehicle.arrival_date ?? null;
time =
normalizeTime(privateVehicle.departure_time) ??
normalizeTime(privateVehicle.arrival_time);
break;
}
const otherTransport = otherTransportByPlanId.get(plan.id);
if (otherTransport) {
date = otherTransport.start_date ?? otherTransport.end_date ?? null;
time = normalizeTime(otherTransport.start_time) ?? normalizeTime(otherTransport.end_time);
}
break;
}
case 'lodging': {
const lodging = lodgingByPlanId.get(plan.id);
if (lodging) {
date = lodging.check_in_date ?? lodging.check_out_date ?? null;
time = normalizeTime(lodging.check_in_time) ?? normalizeTime(lodging.check_out_time);
}
break;
}
case 'activity':
case 'restaurant': {
const experience = experienceByPlanId.get(plan.id);
if (experience) {
date = experience.start_date ?? experience.end_date ?? null;
time = normalizeTime(experience.start_time) ?? normalizeTime(experience.end_time);
}
break;
}
case 'tour': {
if (tourContext) {
date = tourContext.startDate;
time = normalizeTime(tourContext.startTime);
}
break;
}
case 'packing':
case 'todo':
break;
default:
break;
}
if (dayContext?.tour.startDate) {
date = addDaysToDate(dayContext.tour.startDate, dayContext.day.dayNumber - 1);
}
const sortTime = time ?? '24:00';
entries.push({
kind: 'plan',
id: plan.id,
date,
time,
plan,
flightSegmentIndex: null,
tour: tourContext,
tourDay: dayContext?.day ?? null,
sortRank: 1,
sortTime,
sequence: sequence++
});
}
for (const tour of input.packageTours) {
const tourContext = tourByPlanId.get(tour.plan_id);
if (!tourContext) continue;
for (const day of tour.days) {
const dayContext: TourDayContext = {
planId: day.plan_id,
dayNumber: day.day_number,
title: day.title ?? null
};
const dayDate = tour.start_date ? addDaysToDate(tour.start_date, day.day_number - 1) : null;
entries.push({
kind: 'day',
id: `day-${day.plan_id}`,
date: dayDate,
tour: tourContext,
day: dayContext,
sortRank: 0,
sortTime: '00:00',
sequence: sequence++
});
}
}
const scheduledEntries = entries.filter((entry) => entry.date);
const unscheduledEntries = entries
.filter((entry) => !entry.date)
.sort((a, b) => {
if (a.sortRank !== b.sortRank) return a.sortRank - b.sortRank;
if (a.sortTime !== b.sortTime) return a.sortTime.localeCompare(b.sortTime);
return a.sequence - b.sequence;
});
scheduledEntries.sort((a, b) => {
if (a.date !== b.date) return (a.date ?? '').localeCompare(b.date ?? '');
if (a.sortRank !== b.sortRank) return a.sortRank - b.sortRank;
if (a.sortTime !== b.sortTime) return a.sortTime.localeCompare(b.sortTime);
return a.sequence - b.sequence;
});
const grouped = new Map<string, TimelineEntry[]>();
for (const entry of scheduledEntries) {
const date = entry.date as string;
const list = grouped.get(date);
if (list) list.push(entry);
else grouped.set(date, [entry]);
}
const scheduled: TimelineGroup[] = Array.from(grouped.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, groupEntries]) => ({
date,
entries: groupEntries
}));
return { scheduled, unscheduled: unscheduledEntries };
}

View File

@@ -1,6 +1,6 @@
import { redirect } from '@sveltejs/kit';
import { base } from '$app/paths';
import { isAdminUser } from '$lib/server/admin/auth.js';
import { env } from '$env/dynamic/private';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async (event) => {
@@ -9,7 +9,11 @@ export const load: LayoutServerLoad = async (event) => {
redirect(303, `${base}/login`);
}
const isAdmin = isAdminUser(session.user);
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);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
const id = parseInt(event.url.searchParams.get('id') ?? '');
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
data.deleteTourOperator(id);

View File

@@ -1,61 +0,0 @@
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

@@ -1,22 +0,0 @@
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
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);
requireAdmin((await event.locals.auth())?.user?.id);
const formData = await event.request.formData();
const id = parseInt(formData.get('id') as string);

View File

@@ -1,597 +0,0 @@
<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,13 +0,0 @@
import { error } from '@sveltejs/kit';
import { getDashboardData } from '$lib/server/dashboard.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
const session = await event.locals.auth();
const userId = session?.user?.id;
if (!userId) error(401, 'Not authenticated');
return {
dashboard: getDashboardData(userId)
};
};

View File

@@ -1,424 +1,10 @@
<script lang="ts">
import { base } from '$app/paths';
import type { DashboardData, DashboardMapStop } from '$lib/server/dashboard.js';
let { data } = $props();
const dashboard = $derived(data.dashboard) as DashboardData;
const mapLongitudeOffset = 18;
const mapLatitudeOffset = 4;
const markers = $derived(
dashboard.mapStops.map((stop: DashboardMapStop, index: number) => {
const x = ((stop.lon + 180 - mapLongitudeOffset) / 360) * 100;
const y = ((90 - (stop.lat + mapLatitudeOffset)) / 180) * 100;
return {
...stop,
id: `${stop.tripId}-${index}`,
x: Math.min(98, Math.max(2, x)),
y: Math.min(96, Math.max(4, y))
};
})
);
function parseDate(value: string | null): Date | null {
if (!value) return null;
const parsed = new Date(`${value}T00:00:00`);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function formatDate(value: string | null): string {
if (!value) return 'TBD';
return new Date(`${value}T00:00:00`).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
function formatRange(start: string | null, end: string | null): string {
if (!start && !end) return 'Dates not set yet';
return `${formatDate(start)}${formatDate(end)}`;
}
function dayDiffInclusive(start: string | null, end: string | null): number | null {
const startDate = parseDate(start);
const endDate = parseDate(end);
if (!startDate || !endDate) return null;
const diffMs = endDate.getTime() - startDate.getTime();
if (Number.isNaN(diffMs)) return null;
return Math.max(0, Math.round(diffMs / 86400000) + 1);
}
function progressPercent(start: string | null, end: string | null): number | null {
const startDate = parseDate(start);
const endDate = parseDate(end);
if (!startDate || !endDate) return null;
const now = new Date();
const totalMs = endDate.getTime() - startDate.getTime();
if (totalMs <= 0) return null;
const elapsedMs = now.getTime() - startDate.getTime();
return Math.min(100, Math.max(0, Math.round((elapsedMs / totalMs) * 100)));
}
function daysUntil(value: string | null): number | null {
const target = parseDate(value);
if (!target) return null;
const now = new Date();
const diffMs = target.getTime() - now.getTime();
return Math.ceil(diffMs / 86400000);
}
function currentTripDay(start: string | null, end: string | null): { current: number; total: number } | null {
const startDate = parseDate(start);
const endDate = parseDate(end);
const total = dayDiffInclusive(start, end);
if (!startDate || !endDate || !total) return null;
const now = new Date();
const elapsed = Math.floor((now.getTime() - startDate.getTime()) / 86400000) + 1;
return { current: Math.min(total, Math.max(1, elapsed)), total };
}
</script>
<svelte:head>
<title>Dashboard — Trips</title>
</svelte:head>
<div class="dashboard-shell space-y-8">
<header class="rounded-3xl bg-white p-6 shadow-sm">
<p class="text-xs uppercase tracking-[0.2em] text-slate-500">Overview</p>
<h1 class="display mt-2 text-3xl text-slate-900 sm:text-4xl">Dashboard</h1>
<p class="mt-2 text-sm text-slate-600">
Welcome back, {data.session.user?.name ?? data.session.user?.email}. Heres a quick pulse on your
travel story.
</p>
</header>
<section class="grid gap-6 lg:grid-cols-[1.6fr,1fr]">
<div
class="relative overflow-hidden rounded-3xl border border-slate-200 bg-slate-950/90 p-6 text-white"
data-testid="trip-map"
>
<div class="absolute inset-0 map-grid opacity-15"></div>
<div class="absolute -left-20 top-10 h-48 w-48 rounded-full bg-emerald-400/15 blur-3xl"></div>
<div class="absolute right-0 bottom-0 h-56 w-56 rounded-full bg-sky-400/15 blur-3xl"></div>
<img
class="map-base absolute inset-0 h-full w-full object-cover"
src="{base}/world-map.svg"
alt=""
aria-hidden="true"
/>
<div class="relative">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs uppercase tracking-[0.2em] text-emerald-200">World map</p>
<h2 class="display mt-2 text-xl text-white">Past &amp; future routes</h2>
<p class="mt-2 text-sm text-slate-200/80">
Pins highlight places from your journey log and whats still ahead.
</p>
</div>
<div class="flex flex-col gap-2 text-xs text-slate-200">
<div class="flex items-center gap-2">
<span class="legend-dot legend-past"></span>
Past trips
</div>
<div class="flex items-center gap-2">
<span class="legend-dot legend-current"></span>
In progress
</div>
<div class="flex items-center gap-2">
<span class="legend-dot legend-future"></span>
Upcoming
</div>
</div>
</div>
<div class="relative mt-6 h-64">
{#if markers.length === 0}
<div class="flex h-full items-center justify-center rounded-2xl border border-dashed border-white/30 text-sm text-slate-200">
Add destinations to your trips to light up the map.
</div>
{:else}
{#each markers as marker}
<span
class="map-marker map-marker--{marker.status}"
data-testid="map-stop"
data-status={marker.status}
style={`left:${marker.x}%; top:${marker.y}%;`}
title={`${marker.tripName} · ${marker.label}`}
></span>
{/each}
{/if}
</div>
</div>
</div>
<div class="grid gap-4">
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm">
<p class="text-xs uppercase tracking-[0.2em] text-slate-500">Travel diary</p>
<h2 class="display mt-2 text-xl text-slate-900">Where youve been</h2>
<div class="mt-4 grid grid-cols-2 gap-4">
<div class="stat-card">
<span class="stat-label">Past trips</span>
<span class="stat-value" data-testid="stat-past-trips">{dashboard.stats.pastTrips}</span>
</div>
<div class="stat-card">
<span class="stat-label">Days away</span>
<span class="stat-value" data-testid="stat-days-away">{dashboard.stats.daysAway}</span>
</div>
<div class="stat-card">
<span class="stat-label">Countries visited</span>
<span class="stat-value" data-testid="stat-countries">{dashboard.stats.countriesVisited}</span>
</div>
<div class="stat-card">
<span class="stat-label">Cities explored</span>
<span class="stat-value" data-testid="stat-cities">{dashboard.stats.citiesVisited}</span>
</div>
</div>
<div class="mt-5">
<p class="text-xs uppercase tracking-[0.2em] text-slate-500">Top destinations</p>
{#if dashboard.stats.topCountries.length === 0}
<p class="mt-2 text-sm text-slate-500">No past destinations yet.</p>
{:else}
<div class="mt-3 space-y-2">
{#each dashboard.stats.topCountries as country}
<div class="flex items-center justify-between text-sm text-slate-700">
<span>{country.country}</span>
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600">
{country.count} stop{country.count === 1 ? '' : 's'}
</span>
</div>
{/each}
</div>
{/if}
</div>
</div>
<div class="rounded-3xl border border-slate-200 bg-gradient-to-br from-slate-900 via-slate-900 to-emerald-900 p-5 text-white shadow-sm">
<p class="text-xs uppercase tracking-[0.2em] text-emerald-200">Next up</p>
<h2 class="display mt-2 text-xl">Trips in progress</h2>
{#if dashboard.inProgressTrips.length === 0}
<p class="mt-3 text-sm text-emerald-100/80">No trips are underway right now.</p>
{:else}
<div class="mt-4 space-y-3">
{#each dashboard.inProgressTrips as trip}
<a
href="{base}/trips/{trip.id}"
class="block rounded-2xl border border-white/10 bg-white/5 p-4 transition hover:bg-white/10"
>
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-white">{trip.name}</h3>
<span class="badge badge-current">In progress</span>
</div>
<p class="mt-1 text-xs text-emerald-100/80">
{formatRange(trip.start_date, trip.end_date)}
</p>
{#if progressPercent(trip.start_date, trip.end_date) !== null}
<div class="mt-3 h-1.5 w-full rounded-full bg-white/10">
<div
class="h-1.5 rounded-full bg-emerald-300"
style={`width:${progressPercent(trip.start_date, trip.end_date)}%`}
></div>
</div>
{/if}
<p class="mt-2 text-xs text-emerald-100/70">
{#if currentTripDay(trip.start_date, trip.end_date)}
Day {currentTripDay(trip.start_date, trip.end_date)?.current} of {currentTripDay(
trip.start_date,
trip.end_date
)?.total}
·
{/if}
{trip.planCount} plans logged
</p>
</a>
{/each}
</div>
{/if}
</div>
</div>
</section>
<section class="grid gap-6 lg:grid-cols-2">
<div class="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-xs uppercase tracking-[0.2em] text-slate-500">On the horizon</p>
<h2 class="display mt-2 text-xl text-slate-900">Trips being planned</h2>
</div>
<a href="{base}/trips/upcoming" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500 hover:text-slate-800">
View all
</a>
</div>
{#if dashboard.plannedTrips.length === 0}
<p class="mt-4 text-sm text-slate-500">No future trips yet. Start planning the next adventure.</p>
{:else}
<div class="mt-4 space-y-3">
{#each dashboard.plannedTrips as trip}
<a
href="{base}/trips/{trip.id}"
class="block rounded-2xl border border-slate-200 bg-slate-50 p-4 transition hover:bg-white"
>
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-slate-900">{trip.name}</h3>
<span class="badge badge-future">Planning</span>
</div>
<p class="mt-1 text-xs text-slate-500">{formatRange(trip.start_date, trip.end_date)}</p>
<p class="mt-2 text-xs text-slate-600">
{#if daysUntil(trip.start_date) !== null}
Starts in {daysUntil(trip.start_date)} day{daysUntil(trip.start_date) === 1 ? '' : 's'}
{:else}
Dates to be confirmed
{/if}
· {trip.planCount} plans sketched
</p>
</a>
{/each}
</div>
{/if}
</div>
<div class="rounded-3xl border border-slate-200 bg-gradient-to-br from-white via-white to-sky-50 p-6 shadow-sm">
<p class="text-xs uppercase tracking-[0.2em] text-slate-500">Quick actions</p>
<h2 class="display mt-2 text-xl text-slate-900">Build your next story</h2>
<p class="mt-2 text-sm text-slate-600">
Add a destination, log transportation, or save an idea to keep your timeline evolving.
</p>
<div class="mt-4 flex flex-wrap gap-3">
<a
href="{base}/trips/new"
class="rounded-full bg-slate-900 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-white transition hover:bg-slate-800"
>
Plan new trip
</a>
<a
href="{base}/trips/upcoming"
class="rounded-full border border-slate-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-slate-600 transition hover:border-slate-400 hover:text-slate-900"
>
Browse upcoming
</a>
<a
href="{base}/trips/past"
class="rounded-full border border-slate-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-slate-600 transition hover:border-slate-400 hover:text-slate-900"
>
Revisit past trips
</a>
</div>
</div>
</section>
</div>
<style>
.map-grid {
background-image: linear-gradient(90deg, rgba(255, 255, 255, 0.08) 1px, transparent 1px),
linear-gradient(0deg, rgba(255, 255, 255, 0.08) 1px, transparent 1px);
background-size: 48px 48px;
}
.map-base {
opacity: 0.45;
filter: invert(1) grayscale(1);
}
.map-marker {
position: absolute;
width: 0.8rem;
height: 0.8rem;
border-radius: 999px;
transform: translate(-50%, -50%);
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.15), 0 8px 20px rgba(15, 23, 42, 0.35);
}
.map-marker--past {
background: #e2e8f0;
border: 2px solid #94a3b8;
}
.map-marker--current {
background: #34d399;
border: 2px solid #0f766e;
animation: pulse 2.4s ease-in-out infinite;
}
.map-marker--future {
background: #fbbf24;
border: 2px solid #b45309;
}
.legend-dot {
height: 0.6rem;
width: 0.6rem;
border-radius: 999px;
border: 2px solid transparent;
display: inline-block;
}
.legend-past {
background: #e2e8f0;
border-color: #94a3b8;
}
.legend-current {
background: #34d399;
border-color: #0f766e;
}
.legend-future {
background: #fbbf24;
border-color: #b45309;
}
.stat-card {
display: flex;
flex-direction: column;
gap: 0.35rem;
border-radius: 1rem;
background: #f8fafc;
padding: 0.9rem;
}
.stat-label {
font-size: 0.7rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: #64748b;
}
.stat-value {
font-size: 1.6rem;
font-weight: 600;
color: #0f172a;
}
.badge {
border-radius: 999px;
padding: 0.25rem 0.6rem;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.15em;
}
.badge-current {
background: rgba(52, 211, 153, 0.2);
color: #a7f3d0;
border: 1px solid rgba(52, 211, 153, 0.4);
}
.badge-future {
background: #fef3c7;
color: #92400e;
border: 1px solid #fcd34d;
}
@keyframes pulse {
0%,
100% {
transform: translate(-50%, -50%) scale(1);
box-shadow: 0 0 0 4px rgba(52, 211, 153, 0.3), 0 8px 20px rgba(15, 23, 42, 0.35);
}
50% {
transform: translate(-50%, -50%) scale(1.12);
box-shadow: 0 0 0 8px rgba(52, 211, 153, 0.18), 0 8px 20px rgba(15, 23, 42, 0.35);
}
}
</style>
<h1 class="mb-2 text-2xl font-bold text-gray-900">Dashboard</h1>
<p class="text-gray-600">Welcome, {data.session.user?.name ?? data.session.user?.email}.</p>

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,8 @@
import { env } from '$env/dynamic/private';
import type { PageServerLoad } from './$types';
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$/, '');
export const load: PageServerLoad = async () => {
return {
signinUrl: `${authUrl}/signin/synology`,
localSigninUrl: `${authUrl}/callback/local`,
callbackUrl: appRoot,
localAuthEnabled,
error: resolveErrorMessage(url.searchParams.get('error'))
signinUrl: `${env.AUTH_URL}/signin/synology`
};
};

View File

@@ -1,86 +1,22 @@
<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>
<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>
<form bind:this={form} method="POST" action={data.signinUrl} class="hidden">
<input type="hidden" name="csrfToken" />
</form>
{#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 class="flex min-h-[60vh] items-center justify-center">
<p class="text-gray-500">Redirecting to sign in…</p>
</div>

View File

@@ -1,38 +0,0 @@
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);
});
});

View File

@@ -1,33 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg id="svg26389" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="630" width="2670" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="0 0 2670 630.00001">
<metadata id="metadata26394">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<g id="layer5" fill-rule="evenodd" transform="translate(580 -582.36)">
<g fill="#2a0088">
<path id="path27116" d="m1555.9 1052.4 80.21-310h88.682s22.491-2.1667 34.058 7 14.847 23.333 14.847 23.333l48.185 150.02 62.878-180.35h85.815s21.344 0 32.405 8.6667 14.386 21.667 14.386 21.667l70.344 279.67h-83.324l-62.85-238.38-65.966 181.72h-88.328s-10.643 1.0421-20.818-5.8333c-10.175-6.8755-10.495-13.167-10.495-13.167l-53.773-162.71-61.698 238.38z"/>
<path id="path27065" d="m729.66 742.36v61.834l148.82 10v238.17h85.953v-238.17l148.82-10v-61.834z"/>
<path id="path27022" d="m482.33 742.36-155 310h95.279l27.076-57h172.82l26.457 57h94.748l-147.05-289.67s-4.8041-10.669-13.137-15.438c-8.3327-4.7684-17.102-4.8965-17.102-4.8965h-84.094zm52.625 66.943 56.961 123.06h-114.81l57.848-123.06z"/>
<path id="path26992" d="m0 742.36v310h261.67l35.667-71.333h-207.67v-238.67z"/>
<path id="path27070" d="m1255.3 742.36-155 310h95.279l27.076-57h172.82l26.457 57h94.748l-147.05-289.67s-4.804-10.669-13.137-15.438c-8.3327-4.7684-17.102-4.8965-17.102-4.8965h-84.094zm52.625 66.943 56.961 123.06h-114.81l57.848-123.06z"/>
</g>
<g fill="#ed1651">
<path id="path27278" d="m-532.68 841.98 68.786-19.648s-0.73658-4.5943-3.2031-6.8493-6.9031-3.6493-6.9031-3.6493l-95.605-34.728s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v44.459s-0.2385 4.2567 5.927 13.798c6.1656 9.5409 20.77 7.111 20.77 7.111z"/>
<path id="path27260" d="m-258.85 734.71 63.986-35.848s0.96342-6.1943-1.5031-8.4493-5.4031-2.9493-5.4031-2.9493l-276.34-98.614s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v38.333s-0.0385 8.882 6.127 18.423c6.1656 9.5409 16.17 12.011 16.17 12.011z"/>
<path id="path27258" d="m-258.85 846.29 63.986-33.148s1.3634-8.3943-1.1031-10.649c-2.4665-2.255-5.8031-3.4493-5.8031-3.4493l-367.84-133.61s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v38.333s-0.0385 8.882 6.127 18.423c6.1656 9.5409 16.17 12.011 16.17 12.011z"/>
</g>
<g fill="#2a0088">
<path id="path27194" d="m-201.77 687.46s3.7354 0.56621 6.9031 3.6493 3.2031 6.8493 3.2031 6.8493v50.754s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.7111-7.0096 7.7111l-8.6667 3.1481-276.16 100.31s-1.328 0.40995-3.0557 0.40995-3.0557-0.40995-3.0557-0.40995l-76.943-27.949s-1.9088-0.44406-4.6171-4.6869c-3.05-4.76-2.83-14.21-2.83-14.21s0.47846 4.8448 3.217 7.1455 10.037-0.22335 10.037-0.22335l353.41-128.37s14.033-5.7246 14.033-11.541c0-4.0162-5.4667-5.7407-5.4667-5.7407z"/>
<path id="path27140" d="m-201.77 799.04s3.7354 0.56621 6.9031 3.6493 3.2031 6.8493 3.2031 6.8493v50.754s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.7111-7.0096 7.7111l-8.6667 3.1481-276.16 100.31v-68l276.16-100.31s14.033-5.7246 14.033-11.541c0-4.0162-5.4667-5.7407-5.4667-5.7407z"/>
<path id="path27142" d="m-338.86 977.68c2.6431 2.6431 2.6431 8.4596 2.6431 8.4596v37.953s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.711-7.0096 7.711l-8.6667 3.1482-131.61 47.807v-68l131.61-47.807s13.387-5.0845 16.03-2.4414z"/>
<path id="path27168" d="m-423.76 1119.6c2.6431 2.6431 2.6431 8.4595 2.6431 8.4595v37.953s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.711-7.0096 7.711l-8.6667 3.1482-46.71 17.807v-68l46.71-17.807s13.387-5.0845 16.03-2.4414z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -1,33 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg id="svg26389" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="630" width="2670" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="0 0 2670 630.00001">
<metadata id="metadata26394">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<g id="layer5" fill-rule="evenodd" transform="translate(580 -582.36)">
<g fill="#2a0088">
<path id="path27116" d="m1555.9 1052.4 80.21-310h88.682s22.491-2.1667 34.058 7 14.847 23.333 14.847 23.333l48.185 150.02 62.878-180.35h85.815s21.344 0 32.405 8.6667 14.386 21.667 14.386 21.667l70.344 279.67h-83.324l-62.85-238.38-65.966 181.72h-88.328s-10.643 1.0421-20.818-5.8333c-10.175-6.8755-10.495-13.167-10.495-13.167l-53.773-162.71-61.698 238.38z"/>
<path id="path27065" d="m729.66 742.36v61.834l148.82 10v238.17h85.953v-238.17l148.82-10v-61.834z"/>
<path id="path27022" d="m482.33 742.36-155 310h95.279l27.076-57h172.82l26.457 57h94.748l-147.05-289.67s-4.8041-10.669-13.137-15.438c-8.3327-4.7684-17.102-4.8965-17.102-4.8965h-84.094zm52.625 66.943 56.961 123.06h-114.81l57.848-123.06z"/>
<path id="path26992" d="m0 742.36v310h261.67l35.667-71.333h-207.67v-238.67z"/>
<path id="path27070" d="m1255.3 742.36-155 310h95.279l27.076-57h172.82l26.457 57h94.748l-147.05-289.67s-4.804-10.669-13.137-15.438c-8.3327-4.7684-17.102-4.8965-17.102-4.8965h-84.094zm52.625 66.943 56.961 123.06h-114.81l57.848-123.06z"/>
</g>
<g fill="#ed1651">
<path id="path27278" d="m-532.68 841.98 68.786-19.648s-0.73658-4.5943-3.2031-6.8493-6.9031-3.6493-6.9031-3.6493l-95.605-34.728s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v44.459s-0.2385 4.2567 5.927 13.798c6.1656 9.5409 20.77 7.111 20.77 7.111z"/>
<path id="path27260" d="m-258.85 734.71 63.986-35.848s0.96342-6.1943-1.5031-8.4493-5.4031-2.9493-5.4031-2.9493l-276.34-98.614s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v38.333s-0.0385 8.882 6.127 18.423c6.1656 9.5409 16.17 12.011 16.17 12.011z"/>
<path id="path27258" d="m-258.85 846.29 63.986-33.148s1.3634-8.3943-1.1031-10.649c-2.4665-2.255-5.8031-3.4493-5.8031-3.4493l-367.84-133.61s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v38.333s-0.0385 8.882 6.127 18.423c6.1656 9.5409 16.17 12.011 16.17 12.011z"/>
</g>
<g fill="#2a0088">
<path id="path27194" d="m-201.77 687.46s3.7354 0.56621 6.9031 3.6493 3.2031 6.8493 3.2031 6.8493v50.754s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.7111-7.0096 7.7111l-8.6667 3.1481-276.16 100.31s-1.328 0.40995-3.0557 0.40995-3.0557-0.40995-3.0557-0.40995l-76.943-27.949s-1.9088-0.44406-4.6171-4.6869c-3.05-4.76-2.83-14.21-2.83-14.21s0.47846 4.8448 3.217 7.1455 10.037-0.22335 10.037-0.22335l353.41-128.37s14.033-5.7246 14.033-11.541c0-4.0162-5.4667-5.7407-5.4667-5.7407z"/>
<path id="path27140" d="m-201.77 799.04s3.7354 0.56621 6.9031 3.6493 3.2031 6.8493 3.2031 6.8493v50.754s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.7111-7.0096 7.7111l-8.6667 3.1481-276.16 100.31v-68l276.16-100.31s14.033-5.7246 14.033-11.541c0-4.0162-5.4667-5.7407-5.4667-5.7407z"/>
<path id="path27142" d="m-338.86 977.68c2.6431 2.6431 2.6431 8.4596 2.6431 8.4596v37.953s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.711-7.0096 7.711l-8.6667 3.1482-131.61 47.807v-68l131.61-47.807s13.387-5.0845 16.03-2.4414z"/>
<path id="path27168" d="m-423.76 1119.6c2.6431 2.6431 2.6431 8.4595 2.6431 8.4595v37.953s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.711-7.0096 7.711l-8.6667 3.1482-46.71 17.807v-68l46.71-17.807s13.387-5.0845 16.03-2.4414z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -1,33 +1,18 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg id="svg26389" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="630" width="2670" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="0 0 2670 630.00001">
<metadata id="metadata26394">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<g id="layer5" fill-rule="evenodd" transform="translate(580 -582.36)">
<g fill="#2a0088">
<path id="path27116" d="m1555.9 1052.4 80.21-310h88.682s22.491-2.1667 34.058 7 14.847 23.333 14.847 23.333l48.185 150.02 62.878-180.35h85.815s21.344 0 32.405 8.6667 14.386 21.667 14.386 21.667l70.344 279.67h-83.324l-62.85-238.38-65.966 181.72h-88.328s-10.643 1.0421-20.818-5.8333c-10.175-6.8755-10.495-13.167-10.495-13.167l-53.773-162.71-61.698 238.38z"/>
<path id="path27065" d="m729.66 742.36v61.834l148.82 10v238.17h85.953v-238.17l148.82-10v-61.834z"/>
<path id="path27022" d="m482.33 742.36-155 310h95.279l27.076-57h172.82l26.457 57h94.748l-147.05-289.67s-4.8041-10.669-13.137-15.438c-8.3327-4.7684-17.102-4.8965-17.102-4.8965h-84.094zm52.625 66.943 56.961 123.06h-114.81l57.848-123.06z"/>
<path id="path26992" d="m0 742.36v310h261.67l35.667-71.333h-207.67v-238.67z"/>
<path id="path27070" d="m1255.3 742.36-155 310h95.279l27.076-57h172.82l26.457 57h94.748l-147.05-289.67s-4.804-10.669-13.137-15.438c-8.3327-4.7684-17.102-4.8965-17.102-4.8965h-84.094zm52.625 66.943 56.961 123.06h-114.81l57.848-123.06z"/>
</g>
<g fill="#ed1651">
<path id="path27278" d="m-532.68 841.98 68.786-19.648s-0.73658-4.5943-3.2031-6.8493-6.9031-3.6493-6.9031-3.6493l-95.605-34.728s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v44.459s-0.2385 4.2567 5.927 13.798c6.1656 9.5409 20.77 7.111 20.77 7.111z"/>
<path id="path27260" d="m-258.85 734.71 63.986-35.848s0.96342-6.1943-1.5031-8.4493-5.4031-2.9493-5.4031-2.9493l-276.34-98.614s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v38.333s-0.0385 8.882 6.127 18.423c6.1656 9.5409 16.17 12.011 16.17 12.011z"/>
<path id="path27258" d="m-258.85 846.29 63.986-33.148s1.3634-8.3943-1.1031-10.649c-2.4665-2.255-5.8031-3.4493-5.8031-3.4493l-367.84-133.61s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v38.333s-0.0385 8.882 6.127 18.423c6.1656 9.5409 16.17 12.011 16.17 12.011z"/>
</g>
<g fill="#2a0088">
<path id="path27194" d="m-201.77 687.46s3.7354 0.56621 6.9031 3.6493 3.2031 6.8493 3.2031 6.8493v50.754s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.7111-7.0096 7.7111l-8.6667 3.1481-276.16 100.31s-1.328 0.40995-3.0557 0.40995-3.0557-0.40995-3.0557-0.40995l-76.943-27.949s-1.9088-0.44406-4.6171-4.6869c-3.05-4.76-2.83-14.21-2.83-14.21s0.47846 4.8448 3.217 7.1455 10.037-0.22335 10.037-0.22335l353.41-128.37s14.033-5.7246 14.033-11.541c0-4.0162-5.4667-5.7407-5.4667-5.7407z"/>
<path id="path27140" d="m-201.77 799.04s3.7354 0.56621 6.9031 3.6493 3.2031 6.8493 3.2031 6.8493v50.754s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.7111-7.0096 7.7111l-8.6667 3.1481-276.16 100.31v-68l276.16-100.31s14.033-5.7246 14.033-11.541c0-4.0162-5.4667-5.7407-5.4667-5.7407z"/>
<path id="path27142" d="m-338.86 977.68c2.6431 2.6431 2.6431 8.4596 2.6431 8.4596v37.953s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.711-7.0096 7.711l-8.6667 3.1482-131.61 47.807v-68l131.61-47.807s13.387-5.0845 16.03-2.4414z"/>
<path id="path27168" d="m-423.76 1119.6c2.6431 2.6431 2.6431 8.4595 2.6431 8.4595v37.953s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.711-7.0096 7.711l-8.6667 3.1482-46.71 17.807v-68l46.71-17.807s13.387-5.0845 16.03-2.4414z"/>
</g>
</g>
</svg>
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 61">
<title>LATAM Airlines Chile</title>
<desc>LATAM Airlines Chile logo</desc>
<metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:Airline="http://schema.org/Airline">
<rdf:RDF>
<Airline:name>LATAM Airlines Chile</Airline:name>
<Airline:alternateName>LATAM Airlines</Airline:alternateName>
<Airline:iataCode>LA</Airline:iataCode>
<Airline:url>https://airlinelogos.aero/LA</Airline:url>
</rdf:RDF>
</metadata>
<path d="M129.669 47.129v13.726h2.162V47.129h-2.162zm37.725 0v13.726h11.085v-2.002h-8.924v-4.294h7.771v-1.857h-7.771v-3.715h8.632V47.13h-10.793zm-91.428 0v13.726h2.161V47.129h-2.161zm35.696 13.739h10.078v-1.857h-7.916V47.142h-2.162v13.726zm-16.548-6.007c-1.578 0-3.169-.435-4.177-1.001v-5.15h5.622c2.732 0 3.739 1.436 3.739 2.714 0 1.857-1.445 3.438-5.184 3.438zm-33.402.566h-6.338l3.169-6.442 3.169 6.442zm82.782 5.44V49.566l9.507 10.723c.292.435.716.566 1.3.566h1.299V47.129h-2.015v10.868l-9.362-10.301c-.292-.435-.57-.567-1.299-.567h-1.446v13.726h2.016v.013zm-53.557 0v-5.006c1.008.566 2.453.856 4.031.856.862 0 1.445 0 2.161-.145l2.878 4.294h2.307l-3.169-4.861c2.015-.856 3.315-2.569 3.315-4.571 0-2.569-2.162-4.439-6.047-4.439h-7.638v13.871h2.161zm-26.639 0h2.161l-6.484-13.16c-.292-.435-.57-.566-1.008-.566h-1.578l-6.763 13.726h2.148l1.724-3.57h8.062l1.737 3.57zm130.015-14.029c-3.74 0-6.193 1.581-6.193 4.005 0 2.292 1.87 3.148 4.893 3.715l1.445.29c2.162.435 3.315 1.146 3.315 2.292s-1.153 2.002-4.031 2.002c-2.585 0-4.614-.856-5.184-1.291l-.57 2.002c.57.29 2.585 1.146 5.609 1.146 4.031 0 6.338-1.568 6.338-4.005 0-2.292-2.016-3.438-5.185-4.004l-1.445-.29c-2.307-.435-3.023-.856-3.023-2.002s1.299-2.002 3.885-2.002c2.161 0 3.739.435 4.468.711l.438-1.712c-.875-.277-2.453-.856-4.76-.856zm-80.158-14.7h6.073V15.33l10.608-.698v-4.334h-27.289v4.334l10.608.698v16.808zm-43.837 0l2.493-5.032H58.079V10.298h-6.312v21.84h18.551zm23.47-8.444h-8.181l4.137-8.747 4.044 8.747zm54.909 0h-8.194l4.137-8.747 4.057 8.747zm27.753 3.109c.239.777 1.167 1.317 2.188 1.317h6.312l4.681-12.857 4.442 16.888H200l-4.906-19.364c-.464-1.936-1.565-2.476-3.355-2.476h-6.166l-4.442 12.778-3.514-10.854c-.464-1.383-1.326-1.936-2.731-1.936h-6.935l-5.689 21.84h6.007l4.363-16.887 3.818 11.553zm-23.629 5.335h6.63l-10.449-20.457c-.543-1.001-1.087-1.396-2.334-1.396H140.9l-11.005 21.854h6.709l1.949-4.031h12.319l1.949 4.031zm-54.976 0h6.71L94.106 11.694c-.544-1.001-1.087-1.396-2.347-1.396h-5.768l-10.992 21.84h6.696l1.949-4.031h12.319l1.883 4.031zM8.964 54.322l5.384-1.95c1.127-.408 1.127.487 1.127 1.831v2.463c0 1.989-.676 2.226-1.565 2.556L8.977 61v-6.679h-.013zm0-10.907l13.684-4.953c1.127-.408 1.127.487 1.127 1.831v2.45c0 2.002-.676 2.239-1.565 2.569L8.977 50.093v-6.679h-.013zM7.85 27.857L.902 25.341C0 25.025 0 23.813 0 23.366c0 0 0 1.462 1.339.975l34.078-12.316c2.692-.975 2.692-1.726 1.565-2.134 1.127.408 1.127.408 1.127 3.675 0 3.122 0 4.163-1.339 4.65l-26.679 9.642c-1.127.408-1.127.408-2.241 0zm29.146-7.06c1.127.408 1.127 1.159-1.565 2.134L8.964 32.494v6.679L36.77 29.122c1.339-.487 1.339-1.528 1.339-4.65 0-3.267 0-3.267-1.114-3.675z" fill="#1b0088"/>
<g fill="#ed1650">
<path d="M2.917 15.159l11.908 4.308 9.242-3.346L1.79 8.072C.225 7.505 0 7.426 0 8.546v2.898c0 2.661 2.016 3.399 2.917 3.715zm34.079 5.638l-3.7-1.33-9.242 3.346 5.848 2.121 5.516-1.989c2.692-.988 2.692-1.739 1.578-2.147z"/>
<path d="M36.996 9.89L10.767.405C9.189-.161 8.964-.24 8.964.866v2.898c0 2.674 2.016 3.399 2.917 3.728l18.034 6.521 5.516-1.989c2.678-.975 2.678-1.726 1.565-2.134zM1.339 24.34l7.624-2.753-7.174-2.595C.225 18.426 0 18.334 0 19.453v3.912s0 1.462 1.339.975z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -1,33 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg id="svg26389" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="630" width="2670" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="0 0 2670 630.00001">
<metadata id="metadata26394">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<g id="layer5" fill-rule="evenodd" transform="translate(580 -582.36)">
<g fill="#2a0088">
<path id="path27116" d="m1555.9 1052.4 80.21-310h88.682s22.491-2.1667 34.058 7 14.847 23.333 14.847 23.333l48.185 150.02 62.878-180.35h85.815s21.344 0 32.405 8.6667 14.386 21.667 14.386 21.667l70.344 279.67h-83.324l-62.85-238.38-65.966 181.72h-88.328s-10.643 1.0421-20.818-5.8333c-10.175-6.8755-10.495-13.167-10.495-13.167l-53.773-162.71-61.698 238.38z"/>
<path id="path27065" d="m729.66 742.36v61.834l148.82 10v238.17h85.953v-238.17l148.82-10v-61.834z"/>
<path id="path27022" d="m482.33 742.36-155 310h95.279l27.076-57h172.82l26.457 57h94.748l-147.05-289.67s-4.8041-10.669-13.137-15.438c-8.3327-4.7684-17.102-4.8965-17.102-4.8965h-84.094zm52.625 66.943 56.961 123.06h-114.81l57.848-123.06z"/>
<path id="path26992" d="m0 742.36v310h261.67l35.667-71.333h-207.67v-238.67z"/>
<path id="path27070" d="m1255.3 742.36-155 310h95.279l27.076-57h172.82l26.457 57h94.748l-147.05-289.67s-4.804-10.669-13.137-15.438c-8.3327-4.7684-17.102-4.8965-17.102-4.8965h-84.094zm52.625 66.943 56.961 123.06h-114.81l57.848-123.06z"/>
</g>
<g fill="#ed1651">
<path id="path27278" d="m-532.68 841.98 68.786-19.648s-0.73658-4.5943-3.2031-6.8493-6.9031-3.6493-6.9031-3.6493l-95.605-34.728s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v44.459s-0.2385 4.2567 5.927 13.798c6.1656 9.5409 20.77 7.111 20.77 7.111z"/>
<path id="path27260" d="m-258.85 734.71 63.986-35.848s0.96342-6.1943-1.5031-8.4493-5.4031-2.9493-5.4031-2.9493l-276.34-98.614s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v38.333s-0.0385 8.882 6.127 18.423c6.1656 9.5409 16.17 12.011 16.17 12.011z"/>
<path id="path27258" d="m-258.85 846.29 63.986-33.148s1.3634-8.3943-1.1031-10.649c-2.4665-2.255-5.8031-3.4493-5.8031-3.4493l-367.84-133.61s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v38.333s-0.0385 8.882 6.127 18.423c6.1656 9.5409 16.17 12.011 16.17 12.011z"/>
</g>
<g fill="#2a0088">
<path id="path27194" d="m-201.77 687.46s3.7354 0.56621 6.9031 3.6493 3.2031 6.8493 3.2031 6.8493v50.754s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.7111-7.0096 7.7111l-8.6667 3.1481-276.16 100.31s-1.328 0.40995-3.0557 0.40995-3.0557-0.40995-3.0557-0.40995l-76.943-27.949s-1.9088-0.44406-4.6171-4.6869c-3.05-4.76-2.83-14.21-2.83-14.21s0.47846 4.8448 3.217 7.1455 10.037-0.22335 10.037-0.22335l353.41-128.37s14.033-5.7246 14.033-11.541c0-4.0162-5.4667-5.7407-5.4667-5.7407z"/>
<path id="path27140" d="m-201.77 799.04s3.7354 0.56621 6.9031 3.6493 3.2031 6.8493 3.2031 6.8493v50.754s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.7111-7.0096 7.7111l-8.6667 3.1481-276.16 100.31v-68l276.16-100.31s14.033-5.7246 14.033-11.541c0-4.0162-5.4667-5.7407-5.4667-5.7407z"/>
<path id="path27142" d="m-338.86 977.68c2.6431 2.6431 2.6431 8.4596 2.6431 8.4596v37.953s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.711-7.0096 7.711l-8.6667 3.1482-131.61 47.807v-68l131.61-47.807s13.387-5.0845 16.03-2.4414z"/>
<path id="path27168" d="m-423.76 1119.6c2.6431 2.6431 2.6431 8.4595 2.6431 8.4595v37.953s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.711-7.0096 7.711l-8.6667 3.1482-46.71 17.807v-68l46.71-17.807s13.387-5.0845 16.03-2.4414z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -1,33 +1,18 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg id="svg26389" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="630" width="2670" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="0 0 2670 630.00001">
<metadata id="metadata26394">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<g id="layer5" fill-rule="evenodd" transform="translate(580 -582.36)">
<g fill="#2a0088">
<path id="path27116" d="m1555.9 1052.4 80.21-310h88.682s22.491-2.1667 34.058 7 14.847 23.333 14.847 23.333l48.185 150.02 62.878-180.35h85.815s21.344 0 32.405 8.6667 14.386 21.667 14.386 21.667l70.344 279.67h-83.324l-62.85-238.38-65.966 181.72h-88.328s-10.643 1.0421-20.818-5.8333c-10.175-6.8755-10.495-13.167-10.495-13.167l-53.773-162.71-61.698 238.38z"/>
<path id="path27065" d="m729.66 742.36v61.834l148.82 10v238.17h85.953v-238.17l148.82-10v-61.834z"/>
<path id="path27022" d="m482.33 742.36-155 310h95.279l27.076-57h172.82l26.457 57h94.748l-147.05-289.67s-4.8041-10.669-13.137-15.438c-8.3327-4.7684-17.102-4.8965-17.102-4.8965h-84.094zm52.625 66.943 56.961 123.06h-114.81l57.848-123.06z"/>
<path id="path26992" d="m0 742.36v310h261.67l35.667-71.333h-207.67v-238.67z"/>
<path id="path27070" d="m1255.3 742.36-155 310h95.279l27.076-57h172.82l26.457 57h94.748l-147.05-289.67s-4.804-10.669-13.137-15.438c-8.3327-4.7684-17.102-4.8965-17.102-4.8965h-84.094zm52.625 66.943 56.961 123.06h-114.81l57.848-123.06z"/>
</g>
<g fill="#ed1651">
<path id="path27278" d="m-532.68 841.98 68.786-19.648s-0.73658-4.5943-3.2031-6.8493-6.9031-3.6493-6.9031-3.6493l-95.605-34.728s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v44.459s-0.2385 4.2567 5.927 13.798c6.1656 9.5409 20.77 7.111 20.77 7.111z"/>
<path id="path27260" d="m-258.85 734.71 63.986-35.848s0.96342-6.1943-1.5031-8.4493-5.4031-2.9493-5.4031-2.9493l-276.34-98.614s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v38.333s-0.0385 8.882 6.127 18.423c6.1656 9.5409 16.17 12.011 16.17 12.011z"/>
<path id="path27258" d="m-258.85 846.29 63.986-33.148s1.3634-8.3943-1.1031-10.649c-2.4665-2.255-5.8031-3.4493-5.8031-3.4493l-367.84-133.61s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v38.333s-0.0385 8.882 6.127 18.423c6.1656 9.5409 16.17 12.011 16.17 12.011z"/>
</g>
<g fill="#2a0088">
<path id="path27194" d="m-201.77 687.46s3.7354 0.56621 6.9031 3.6493 3.2031 6.8493 3.2031 6.8493v50.754s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.7111-7.0096 7.7111l-8.6667 3.1481-276.16 100.31s-1.328 0.40995-3.0557 0.40995-3.0557-0.40995-3.0557-0.40995l-76.943-27.949s-1.9088-0.44406-4.6171-4.6869c-3.05-4.76-2.83-14.21-2.83-14.21s0.47846 4.8448 3.217 7.1455 10.037-0.22335 10.037-0.22335l353.41-128.37s14.033-5.7246 14.033-11.541c0-4.0162-5.4667-5.7407-5.4667-5.7407z"/>
<path id="path27140" d="m-201.77 799.04s3.7354 0.56621 6.9031 3.6493 3.2031 6.8493 3.2031 6.8493v50.754s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.7111-7.0096 7.7111l-8.6667 3.1481-276.16 100.31v-68l276.16-100.31s14.033-5.7246 14.033-11.541c0-4.0162-5.4667-5.7407-5.4667-5.7407z"/>
<path id="path27142" d="m-338.86 977.68c2.6431 2.6431 2.6431 8.4596 2.6431 8.4596v37.953s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.711-7.0096 7.711l-8.6667 3.1482-131.61 47.807v-68l131.61-47.807s13.387-5.0845 16.03-2.4414z"/>
<path id="path27168" d="m-423.76 1119.6c2.6431 2.6431 2.6431 8.4595 2.6431 8.4595v37.953s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.711-7.0096 7.711l-8.6667 3.1482-46.71 17.807v-68l46.71-17.807s13.387-5.0845 16.03-2.4414z"/>
</g>
</g>
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 61">
<title>LATAM Airlines Paraguay</title>
<desc>LATAM Airlines Paraguay logo</desc>
<metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:Airline="http://schema.org/Airline">
<rdf:RDF>
<Airline:name>LATAM Airlines Paraguay</Airline:name>
<Airline:alternateName>LATAM Airlines</Airline:alternateName>
<Airline:iataCode>PZ</Airline:iataCode>
<Airline:url>https://airlinelogos.aero/PZ</Airline:url>
</rdf:RDF>
</metadata>
<path d="M129.669 47.129v13.726h2.162V47.129h-2.162zm37.725 0v13.726h11.085v-2.002h-8.924v-4.294h7.771v-1.857h-7.771v-3.715h8.632V47.13h-10.793zm-91.428 0v13.726h2.161V47.129h-2.161zm35.696 13.739h10.078v-1.857h-7.916V47.142h-2.162v13.726zm-16.548-6.007c-1.578 0-3.169-.435-4.177-1.001v-5.15h5.622c2.732 0 3.739 1.436 3.739 2.714 0 1.857-1.445 3.438-5.184 3.438zm-33.402.566h-6.338l3.169-6.442 3.169 6.442zm82.782 5.44V49.566l9.507 10.723c.292.435.716.566 1.3.566h1.299V47.129h-2.015v10.868l-9.362-10.301c-.292-.435-.57-.567-1.299-.567h-1.446v13.726h2.016v.013zm-53.557 0v-5.006c1.008.566 2.453.856 4.031.856.862 0 1.445 0 2.161-.145l2.878 4.294h2.307l-3.169-4.861c2.015-.856 3.315-2.569 3.315-4.571 0-2.569-2.162-4.439-6.047-4.439h-7.638v13.871h2.161zm-26.639 0h2.161l-6.484-13.16c-.292-.435-.57-.566-1.008-.566h-1.578l-6.763 13.726h2.148l1.724-3.57h8.062l1.737 3.57zm130.015-14.029c-3.74 0-6.193 1.581-6.193 4.005 0 2.292 1.87 3.148 4.893 3.715l1.445.29c2.162.435 3.315 1.146 3.315 2.292s-1.153 2.002-4.031 2.002c-2.585 0-4.614-.856-5.184-1.291l-.57 2.002c.57.29 2.585 1.146 5.609 1.146 4.031 0 6.338-1.568 6.338-4.005 0-2.292-2.016-3.438-5.185-4.004l-1.445-.29c-2.307-.435-3.023-.856-3.023-2.002s1.299-2.002 3.885-2.002c2.161 0 3.739.435 4.468.711l.438-1.712c-.875-.277-2.453-.856-4.76-.856zm-80.158-14.7h6.073V15.33l10.608-.698v-4.334h-27.289v4.334l10.608.698v16.808zm-43.837 0l2.493-5.032H58.079V10.298h-6.312v21.84h18.551zm23.47-8.444h-8.181l4.137-8.747 4.044 8.747zm54.909 0h-8.194l4.137-8.747 4.057 8.747zm27.753 3.109c.239.777 1.167 1.317 2.188 1.317h6.312l4.681-12.857 4.442 16.888H200l-4.906-19.364c-.464-1.936-1.565-2.476-3.355-2.476h-6.166l-4.442 12.778-3.514-10.854c-.464-1.383-1.326-1.936-2.731-1.936h-6.935l-5.689 21.84h6.007l4.363-16.887 3.818 11.553zm-23.629 5.335h6.63l-10.449-20.457c-.543-1.001-1.087-1.396-2.334-1.396H140.9l-11.005 21.854h6.709l1.949-4.031h12.319l1.949 4.031zm-54.976 0h6.71L94.106 11.694c-.544-1.001-1.087-1.396-2.347-1.396h-5.768l-10.992 21.84h6.696l1.949-4.031h12.319l1.883 4.031zM8.964 54.322l5.384-1.95c1.127-.408 1.127.487 1.127 1.831v2.463c0 1.989-.676 2.226-1.565 2.556L8.977 61v-6.679h-.013zm0-10.907l13.684-4.953c1.127-.408 1.127.487 1.127 1.831v2.45c0 2.002-.676 2.239-1.565 2.569L8.977 50.093v-6.679h-.013zM7.85 27.857L.902 25.341C0 25.025 0 23.813 0 23.366c0 0 0 1.462 1.339.975l34.078-12.316c2.692-.975 2.692-1.726 1.565-2.134 1.127.408 1.127.408 1.127 3.675 0 3.122 0 4.163-1.339 4.65l-26.679 9.642c-1.127.408-1.127.408-2.241 0zm29.146-7.06c1.127.408 1.127 1.159-1.565 2.134L8.964 32.494v6.679L36.77 29.122c1.339-.487 1.339-1.528 1.339-4.65 0-3.267 0-3.267-1.114-3.675z" fill="#1b0088"/>
<g fill="#ed1650">
<path d="M2.917 15.159l11.908 4.308 9.242-3.346L1.79 8.072C.225 7.505 0 7.426 0 8.546v2.898c0 2.661 2.016 3.399 2.917 3.715zm34.079 5.638l-3.7-1.33-9.242 3.346 5.848 2.121 5.516-1.989c2.692-.988 2.692-1.739 1.578-2.147z"/>
<path d="M36.996 9.89L10.767.405C9.189-.161 8.964-.24 8.964.866v2.898c0 2.674 2.016 3.399 2.917 3.728l18.034 6.521 5.516-1.989c2.678-.975 2.678-1.726 1.565-2.134zM1.339 24.34l7.624-2.753-7.174-2.595C.225 18.426 0 18.334 0 19.453v3.912s0 1.462 1.339.975z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -1,33 +1,18 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg id="svg26389" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="630" width="2670" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="0 0 2670 630.00001">
<metadata id="metadata26394">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<g id="layer5" fill-rule="evenodd" transform="translate(580 -582.36)">
<g fill="#2a0088">
<path id="path27116" d="m1555.9 1052.4 80.21-310h88.682s22.491-2.1667 34.058 7 14.847 23.333 14.847 23.333l48.185 150.02 62.878-180.35h85.815s21.344 0 32.405 8.6667 14.386 21.667 14.386 21.667l70.344 279.67h-83.324l-62.85-238.38-65.966 181.72h-88.328s-10.643 1.0421-20.818-5.8333c-10.175-6.8755-10.495-13.167-10.495-13.167l-53.773-162.71-61.698 238.38z"/>
<path id="path27065" d="m729.66 742.36v61.834l148.82 10v238.17h85.953v-238.17l148.82-10v-61.834z"/>
<path id="path27022" d="m482.33 742.36-155 310h95.279l27.076-57h172.82l26.457 57h94.748l-147.05-289.67s-4.8041-10.669-13.137-15.438c-8.3327-4.7684-17.102-4.8965-17.102-4.8965h-84.094zm52.625 66.943 56.961 123.06h-114.81l57.848-123.06z"/>
<path id="path26992" d="m0 742.36v310h261.67l35.667-71.333h-207.67v-238.67z"/>
<path id="path27070" d="m1255.3 742.36-155 310h95.279l27.076-57h172.82l26.457 57h94.748l-147.05-289.67s-4.804-10.669-13.137-15.438c-8.3327-4.7684-17.102-4.8965-17.102-4.8965h-84.094zm52.625 66.943 56.961 123.06h-114.81l57.848-123.06z"/>
</g>
<g fill="#ed1651">
<path id="path27278" d="m-532.68 841.98 68.786-19.648s-0.73658-4.5943-3.2031-6.8493-6.9031-3.6493-6.9031-3.6493l-95.605-34.728s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v44.459s-0.2385 4.2567 5.927 13.798c6.1656 9.5409 20.77 7.111 20.77 7.111z"/>
<path id="path27260" d="m-258.85 734.71 63.986-35.848s0.96342-6.1943-1.5031-8.4493-5.4031-2.9493-5.4031-2.9493l-276.34-98.614s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v38.333s-0.0385 8.882 6.127 18.423c6.1656 9.5409 16.17 12.011 16.17 12.011z"/>
<path id="path27258" d="m-258.85 846.29 63.986-33.148s1.3634-8.3943-1.1031-10.649c-2.4665-2.255-5.8031-3.4493-5.8031-3.4493l-367.84-133.61s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v38.333s-0.0385 8.882 6.127 18.423c6.1656 9.5409 16.17 12.011 16.17 12.011z"/>
</g>
<g fill="#2a0088">
<path id="path27194" d="m-201.77 687.46s3.7354 0.56621 6.9031 3.6493 3.2031 6.8493 3.2031 6.8493v50.754s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.7111-7.0096 7.7111l-8.6667 3.1481-276.16 100.31s-1.328 0.40995-3.0557 0.40995-3.0557-0.40995-3.0557-0.40995l-76.943-27.949s-1.9088-0.44406-4.6171-4.6869c-3.05-4.76-2.83-14.21-2.83-14.21s0.47846 4.8448 3.217 7.1455 10.037-0.22335 10.037-0.22335l353.41-128.37s14.033-5.7246 14.033-11.541c0-4.0162-5.4667-5.7407-5.4667-5.7407z"/>
<path id="path27140" d="m-201.77 799.04s3.7354 0.56621 6.9031 3.6493 3.2031 6.8493 3.2031 6.8493v50.754s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.7111-7.0096 7.7111l-8.6667 3.1481-276.16 100.31v-68l276.16-100.31s14.033-5.7246 14.033-11.541c0-4.0162-5.4667-5.7407-5.4667-5.7407z"/>
<path id="path27142" d="m-338.86 977.68c2.6431 2.6431 2.6431 8.4596 2.6431 8.4596v37.953s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.711-7.0096 7.711l-8.6667 3.1482-131.61 47.807v-68l131.61-47.807s13.387-5.0845 16.03-2.4414z"/>
<path id="path27168" d="m-423.76 1119.6c2.6431 2.6431 2.6431 8.4595 2.6431 8.4595v37.953s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.711-7.0096 7.711l-8.6667 3.1482-46.71 17.807v-68l46.71-17.807s13.387-5.0845 16.03-2.4414z"/>
</g>
</g>
</svg>
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 61">
<title>LATAM Airlines Ecuador</title>
<desc>LATAM Airlines Ecuador logo</desc>
<metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:Airline="http://schema.org/Airline">
<rdf:RDF>
<Airline:name>LATAM Airlines Ecuador</Airline:name>
<Airline:alternateName>LATAM Airlines</Airline:alternateName>
<Airline:iataCode>XL</Airline:iataCode>
<Airline:url>https://airlinelogos.aero/XL</Airline:url>
</rdf:RDF>
</metadata>
<path d="M129.669 47.129v13.726h2.162V47.129h-2.162zm37.725 0v13.726h11.085v-2.002h-8.924v-4.294h7.771v-1.857h-7.771v-3.715h8.632V47.13h-10.793zm-91.428 0v13.726h2.161V47.129h-2.161zm35.696 13.739h10.078v-1.857h-7.916V47.142h-2.162v13.726zm-16.548-6.007c-1.578 0-3.169-.435-4.177-1.001v-5.15h5.622c2.732 0 3.739 1.436 3.739 2.714 0 1.857-1.445 3.438-5.184 3.438zm-33.402.566h-6.338l3.169-6.442 3.169 6.442zm82.782 5.44V49.566l9.507 10.723c.292.435.716.566 1.3.566h1.299V47.129h-2.015v10.868l-9.362-10.301c-.292-.435-.57-.567-1.299-.567h-1.446v13.726h2.016v.013zm-53.557 0v-5.006c1.008.566 2.453.856 4.031.856.862 0 1.445 0 2.161-.145l2.878 4.294h2.307l-3.169-4.861c2.015-.856 3.315-2.569 3.315-4.571 0-2.569-2.162-4.439-6.047-4.439h-7.638v13.871h2.161zm-26.639 0h2.161l-6.484-13.16c-.292-.435-.57-.566-1.008-.566h-1.578l-6.763 13.726h2.148l1.724-3.57h8.062l1.737 3.57zm130.015-14.029c-3.74 0-6.193 1.581-6.193 4.005 0 2.292 1.87 3.148 4.893 3.715l1.445.29c2.162.435 3.315 1.146 3.315 2.292s-1.153 2.002-4.031 2.002c-2.585 0-4.614-.856-5.184-1.291l-.57 2.002c.57.29 2.585 1.146 5.609 1.146 4.031 0 6.338-1.568 6.338-4.005 0-2.292-2.016-3.438-5.185-4.004l-1.445-.29c-2.307-.435-3.023-.856-3.023-2.002s1.299-2.002 3.885-2.002c2.161 0 3.739.435 4.468.711l.438-1.712c-.875-.277-2.453-.856-4.76-.856zm-80.158-14.7h6.073V15.33l10.608-.698v-4.334h-27.289v4.334l10.608.698v16.808zm-43.837 0l2.493-5.032H58.079V10.298h-6.312v21.84h18.551zm23.47-8.444h-8.181l4.137-8.747 4.044 8.747zm54.909 0h-8.194l4.137-8.747 4.057 8.747zm27.753 3.109c.239.777 1.167 1.317 2.188 1.317h6.312l4.681-12.857 4.442 16.888H200l-4.906-19.364c-.464-1.936-1.565-2.476-3.355-2.476h-6.166l-4.442 12.778-3.514-10.854c-.464-1.383-1.326-1.936-2.731-1.936h-6.935l-5.689 21.84h6.007l4.363-16.887 3.818 11.553zm-23.629 5.335h6.63l-10.449-20.457c-.543-1.001-1.087-1.396-2.334-1.396H140.9l-11.005 21.854h6.709l1.949-4.031h12.319l1.949 4.031zm-54.976 0h6.71L94.106 11.694c-.544-1.001-1.087-1.396-2.347-1.396h-5.768l-10.992 21.84h6.696l1.949-4.031h12.319l1.883 4.031zM8.964 54.322l5.384-1.95c1.127-.408 1.127.487 1.127 1.831v2.463c0 1.989-.676 2.226-1.565 2.556L8.977 61v-6.679h-.013zm0-10.907l13.684-4.953c1.127-.408 1.127.487 1.127 1.831v2.45c0 2.002-.676 2.239-1.565 2.569L8.977 50.093v-6.679h-.013zM7.85 27.857L.902 25.341C0 25.025 0 23.813 0 23.366c0 0 0 1.462 1.339.975l34.078-12.316c2.692-.975 2.692-1.726 1.565-2.134 1.127.408 1.127.408 1.127 3.675 0 3.122 0 4.163-1.339 4.65l-26.679 9.642c-1.127.408-1.127.408-2.241 0zm29.146-7.06c1.127.408 1.127 1.159-1.565 2.134L8.964 32.494v6.679L36.77 29.122c1.339-.487 1.339-1.528 1.339-4.65 0-3.267 0-3.267-1.114-3.675z" fill="#1b0088"/>
<g fill="#ed1650">
<path d="M2.917 15.159l11.908 4.308 9.242-3.346L1.79 8.072C.225 7.505 0 7.426 0 8.546v2.898c0 2.661 2.016 3.399 2.917 3.715zm34.079 5.638l-3.7-1.33-9.242 3.346 5.848 2.121 5.516-1.989c2.692-.988 2.692-1.739 1.578-2.147z"/>
<path d="M36.996 9.89L10.767.405C9.189-.161 8.964-.24 8.964.866v2.898c0 2.674 2.016 3.399 2.917 3.728l18.034 6.521 5.516-1.989c2.678-.975 2.678-1.726 1.565-2.134zM1.339 24.34l7.624-2.753-7.174-2.595C.225 18.426 0 18.334 0 19.453v3.912s0 1.462 1.339.975z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 83 KiB

View File

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