Compare commits
1 Commits
c12b21ae8a
...
ai-agent/3
| Author | SHA1 | Date | |
|---|---|---|---|
| ff30b37c36 |
@@ -10,15 +10,6 @@ AUTH_URL=https://cloud.campbellwireless.net/trips/auth
|
|||||||
# Auth.js secret — generate with: openssl rand -base64 32
|
# Auth.js secret — generate with: openssl rand -base64 32
|
||||||
AUTH_SECRET=
|
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 — comma-separated user IDs (from auth provider) that can access /admin
|
||||||
ADMIN_USER_IDS=
|
ADMIN_USER_IDS=
|
||||||
|
|
||||||
|
|||||||
21
.env.test
@@ -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
|
|
||||||
@@ -31,14 +31,6 @@
|
|||||||
- Framework: Vitest with `node` environment.
|
- Framework: Vitest with `node` environment.
|
||||||
- Test file pattern: `src/**/*.test.ts`.
|
- Test file pattern: `src/**/*.test.ts`.
|
||||||
- Keep tests near the code they cover; use `src/tests/stubs` for runtime stubbing.
|
- Keep tests near the code they cover; use `src/tests/stubs` for runtime stubbing.
|
||||||
- For every user-facing feature or behavior change, add/update Playwright e2e coverage under `e2e/`.
|
|
||||||
- When work is tied to a Gitea issue, record an e2e run video and upload it to that issue.
|
|
||||||
- One-off video run: `PW_VIDEO_MODE=on PW_TRACE_MODE=on bunx playwright test <spec>`
|
|
||||||
- Prefer comment-level attachments: post a comment first, then upload the video to that comment.
|
|
||||||
- Create comment: `tea comment -l <login> -r <owner>/<repo> <issue-index> "<message>"`
|
|
||||||
- Upload with `curl` (example, `tea api` in this repo's toolchain does not send multipart correctly):
|
|
||||||
- `TOKEN=$(awk '/- name: cloud.campbellwireless.net/{f=1} f && $1=="token:"{print $2; exit}' "$HOME/Library/Application Support/tea/config.yml")`
|
|
||||||
- `curl -fsS -X POST "https://cloud.campbellwireless.net/git/api/v1/repos/{owner}/{repo}/issues/comments/{comment_id}/assets" -H "Authorization: token $TOKEN" -F "name=<filename>" -F "attachment=@<path>"`
|
|
||||||
|
|
||||||
## Commit & Pull Request Guidelines
|
## Commit & Pull Request Guidelines
|
||||||
- Commit messages currently follow a light “scope) message” pattern, e.g.,
|
- Commit messages currently follow a light “scope) message” pattern, e.g.,
|
||||||
|
|||||||
12
CLAUDE.md
@@ -28,18 +28,6 @@ After making changes, always verify:
|
|||||||
1. `bun run lint` — must exit with 0 errors (warnings are acceptable)
|
1. `bun run lint` — must exit with 0 errors (warnings are acceptable)
|
||||||
2. `bun run test` — all tests must pass
|
2. `bun run test` — all tests must pass
|
||||||
|
|
||||||
For user-facing feature work, also add/update e2e coverage and validate it:
|
|
||||||
3. `bunx playwright test <target spec or suite>`
|
|
||||||
|
|
||||||
When work maps to a Gitea issue, upload an e2e run video to the issue:
|
|
||||||
- Generate one-off video artifacts with:
|
|
||||||
- `PW_VIDEO_MODE=on PW_TRACE_MODE=on bunx playwright test <target spec>`
|
|
||||||
- Prefer comment-level attachments: create a comment first, then attach video to that comment.
|
|
||||||
- `tea comment -l <login> -r <owner>/<repo> <issue-index> "<message>"`
|
|
||||||
- Upload with `curl` (the current `tea api` build here does not send multipart/form-data correctly for attachments):
|
|
||||||
- `TOKEN=$(awk '/- name: cloud.campbellwireless.net/{f=1} f && $1=="token:"{print $2; exit}' "$HOME/Library/Application Support/tea/config.yml")`
|
|
||||||
- `curl -fsS -X POST "https://cloud.campbellwireless.net/git/api/v1/repos/{owner}/{repo}/issues/comments/{comment_id}/assets" -H "Authorization: token $TOKEN" -F "name=<filename>" -F "attachment=@<path>"`
|
|
||||||
|
|
||||||
### Write unit tests after every major feature
|
### Write unit tests after every major feature
|
||||||
|
|
||||||
When adding or significantly modifying server-side business logic (files under `src/lib/server/`), write corresponding unit tests in a `.test.ts` file alongside the module (e.g. `src/lib/server/lodgings.test.ts`).
|
When adding or significantly modifying server-side business logic (files under `src/lib/server/`), write corresponding unit tests in a `.test.ts` file alongside the module (e.g. `src/lib/server/lodgings.test.ts`).
|
||||||
|
|||||||
3
HELLOWORLD.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Hello developers!
|
||||||
|
|
||||||
|
Welcome to the trips project. We are glad you are here and look forward to your contributions.
|
||||||
13
README.md
@@ -43,19 +43,6 @@ bun install
|
|||||||
bun run dev
|
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
|
## Quality and Tests
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
21
bun.lock
@@ -6,19 +6,16 @@
|
|||||||
"name": "trips",
|
"name": "trips",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/sveltekit": "^1.11.1",
|
"@auth/sveltekit": "^1.11.1",
|
||||||
"argon2": "^0.41.1",
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.4",
|
"@biomejs/biome": "^2.4.4",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@playwright/test": "^1.58.2",
|
|
||||||
"@sveltejs/adapter-node": "^5.5.3",
|
"@sveltejs/adapter-node": "^5.5.3",
|
||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.50.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/vite": "^4.2.0",
|
"@tailwindcss/vite": "^4.2.0",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"bun-types": "^1.3.9",
|
"bun-types": "^1.3.9",
|
||||||
"dotenv": "^17.3.1",
|
|
||||||
"eslint": "^10.0.0",
|
"eslint": "^10.0.0",
|
||||||
"eslint-plugin-svelte": "^3.15.0",
|
"eslint-plugin-svelte": "^3.15.0",
|
||||||
"globals": "^17.3.0",
|
"globals": "^17.3.0",
|
||||||
@@ -156,10 +153,6 @@
|
|||||||
|
|
||||||
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
|
"@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=="],
|
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||||
|
|
||||||
"@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@29.0.0", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-U2YHaxR2cU/yAiwKJtJRhnyLk7cifnQw0zUpISsocBDoHDJn+HTV74ABqnwr5bEgWUwFZC9oFL6wLe21lHu5eQ=="],
|
"@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@29.0.0", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-U2YHaxR2cU/yAiwKJtJRhnyLk7cifnQw0zUpISsocBDoHDJn+HTV74ABqnwr5bEgWUwFZC9oFL6wLe21lHu5eQ=="],
|
||||||
@@ -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=="],
|
"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=="],
|
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||||
|
|
||||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||||
@@ -362,8 +353,6 @@
|
|||||||
|
|
||||||
"devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="],
|
"devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="],
|
||||||
|
|
||||||
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
|
|
||||||
|
|
||||||
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
|
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
|
||||||
|
|
||||||
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
||||||
@@ -520,10 +509,6 @@
|
|||||||
|
|
||||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
"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=="],
|
"oauth4webapi": ["oauth4webapi@3.8.5", "", {}, "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg=="],
|
||||||
|
|
||||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||||
@@ -546,10 +531,6 @@
|
|||||||
|
|
||||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
|
|
||||||
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
|
|
||||||
|
|
||||||
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
|
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||||
|
|
||||||
"postcss-load-config": ["postcss-load-config@3.1.4", "", { "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg=="],
|
"postcss-load-config": ["postcss-load-config@3.1.4", "", { "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg=="],
|
||||||
@@ -684,8 +665,6 @@
|
|||||||
|
|
||||||
"eslint-plugin-svelte/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
|
"eslint-plugin-svelte/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
|
||||||
|
|
||||||
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
|
||||||
|
|
||||||
"svelte-eslint-parser/eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
"svelte-eslint-parser/eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||||
|
|
||||||
"svelte-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
"svelte-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
@@ -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/);
|
|
||||||
});
|
|
||||||
@@ -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/);
|
|
||||||
});
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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 });
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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}`);
|
|
||||||
@@ -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;
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
@@ -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 })
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
10
package.json
@@ -14,22 +14,17 @@
|
|||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"test": "bunx --bun svelte-kit sync && bunx --bun vitest run",
|
"test": "bunx --bun svelte-kit sync && bunx --bun vitest run",
|
||||||
"test:watch": "bunx --bun svelte-kit sync && bunx --bun vitest",
|
"test:watch": "bunx --bun svelte-kit sync && bunx --bun vitest",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage"
|
||||||
"test:e2e": "bunx playwright test",
|
|
||||||
"test:e2e:ui": "bunx playwright test --ui",
|
|
||||||
"test:e2e:debug": "bunx playwright test --debug"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.4",
|
"@biomejs/biome": "^2.4.4",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@playwright/test": "^1.58.2",
|
|
||||||
"@sveltejs/adapter-node": "^5.5.3",
|
"@sveltejs/adapter-node": "^5.5.3",
|
||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.50.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/vite": "^4.2.0",
|
"@tailwindcss/vite": "^4.2.0",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"bun-types": "^1.3.9",
|
"bun-types": "^1.3.9",
|
||||||
"dotenv": "^17.3.1",
|
|
||||||
"eslint": "^10.0.0",
|
"eslint": "^10.0.0",
|
||||||
"eslint-plugin-svelte": "^3.15.0",
|
"eslint-plugin-svelte": "^3.15.0",
|
||||||
"globals": "^17.3.0",
|
"globals": "^17.3.0",
|
||||||
@@ -45,7 +40,6 @@
|
|||||||
"vitest": "^4.0.18"
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/sveltekit": "^1.11.1",
|
"@auth/sveltekit": "^1.11.1"
|
||||||
"argon2": "^0.41.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
54
src/auth.ts
@@ -1,8 +1,5 @@
|
|||||||
import { SvelteKitAuth } from '@auth/sveltekit';
|
import { SvelteKitAuth } from '@auth/sveltekit';
|
||||||
import Credentials from '@auth/core/providers/credentials';
|
|
||||||
import { env } from '$env/dynamic/private';
|
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({
|
export const { handle, signIn, signOut } = SvelteKitAuth({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -21,59 +18,12 @@ export const { handle, signIn, signOut } = SvelteKitAuth({
|
|||||||
email: profile.email as string | undefined
|
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,
|
trustHost: true,
|
||||||
pages: {
|
|
||||||
signIn: '/trips/login'
|
|
||||||
},
|
|
||||||
callbacks: {
|
callbacks: {
|
||||||
jwt({ token, profile, user }) {
|
jwt({ token, profile }) {
|
||||||
const details = profile as
|
if (profile?.sub) token.sub = profile.sub as string;
|
||||||
| {
|
|
||||||
sub?: string;
|
|
||||||
name?: string;
|
|
||||||
email?: string;
|
|
||||||
username?: string;
|
|
||||||
preferred_username?: string;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
if (details?.sub) {
|
|
||||||
token.sub = details.sub as string;
|
|
||||||
upsertUserFromAuth({
|
|
||||||
id: details.sub,
|
|
||||||
username: details.username ?? details.preferred_username ?? details.name,
|
|
||||||
fullName: details.name ?? details.username ?? details.preferred_username,
|
|
||||||
email: details.email,
|
|
||||||
authSource: 'OIDC - Synology'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (user?.id) {
|
|
||||||
token.sub = user.id as string;
|
|
||||||
}
|
|
||||||
return token;
|
return token;
|
||||||
},
|
},
|
||||||
session({ session, token }) {
|
session({ session, token }) {
|
||||||
|
|||||||
@@ -42,15 +42,6 @@
|
|||||||
General
|
General
|
||||||
</a>
|
</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">
|
<p class="mt-4 px-3 pb-1 text-xs font-semibold tracking-wider text-gray-400 uppercase">
|
||||||
Reference Data
|
Reference Data
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -4,36 +4,28 @@
|
|||||||
import type { Trip } from '$lib/server/trips.js';
|
import type { Trip } from '$lib/server/trips.js';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
trips?: Trip[];
|
trips: Trip[];
|
||||||
title?: string;
|
title: string;
|
||||||
emptyMessage?: string;
|
emptyMessage: string;
|
||||||
}
|
}
|
||||||
let { trips = [], title = 'Trips', emptyMessage = 'No trips yet.' }: Props = $props();
|
let { trips, title, emptyMessage }: Props = $props();
|
||||||
const tripList = $derived(trips ?? []);
|
|
||||||
|
|
||||||
let view = $state<'tile' | 'table'>('tile');
|
let view = $state<'tile' | 'table'>('tile');
|
||||||
</script>
|
</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>
|
<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">
|
<div class="flex items-center gap-1 rounded-md border border-gray-200 bg-gray-50 p-1">
|
||||||
<button
|
<button
|
||||||
onclick={() => (view = 'tile')}
|
onclick={() => view = 'tile'}
|
||||||
title="Tile view"
|
title="Tile view"
|
||||||
class="rounded p-1.5 transition-colors"
|
class="rounded p-1.5 transition-colors"
|
||||||
style={view === 'tile' ? 'background:#fff; box-shadow:0 1px 2px rgba(0,0,0,0.08);' : ''}
|
style={view === 'tile' ? 'background:#fff; box-shadow:0 1px 2px rgba(0,0,0,0.08);' : ''}
|
||||||
>
|
>
|
||||||
<!-- Grid icon -->
|
<!-- Grid icon -->
|
||||||
<svg
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={view === 'tile' ? '#111827' : '#9ca3af'} stroke-width="2">
|
||||||
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="3" y="3" width="7" height="7" rx="1" />
|
||||||
<rect x="14" 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" />
|
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||||
@@ -41,20 +33,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => (view = 'table')}
|
onclick={() => view = 'table'}
|
||||||
title="Table view"
|
title="Table view"
|
||||||
class="rounded p-1.5 transition-colors"
|
class="rounded p-1.5 transition-colors"
|
||||||
style={view === 'table' ? 'background:#fff; box-shadow:0 1px 2px rgba(0,0,0,0.08);' : ''}
|
style={view === 'table' ? 'background:#fff; box-shadow:0 1px 2px rgba(0,0,0,0.08);' : ''}
|
||||||
>
|
>
|
||||||
<!-- List icon -->
|
<!-- List icon -->
|
||||||
<svg
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={view === 'table' ? '#111827' : '#9ca3af'} stroke-width="2">
|
||||||
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="6" x2="21" y2="6" />
|
||||||
<line x1="3" y1="12" x2="21" y2="12" />
|
<line x1="3" y1="12" x2="21" y2="12" />
|
||||||
<line x1="3" y1="18" x2="21" y2="18" />
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
@@ -64,14 +49,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if tripList.length === 0}
|
{#if trips.length === 0}
|
||||||
<p class="text-gray-500">{emptyMessage}</p>
|
<p class="text-gray-500">{emptyMessage}</p>
|
||||||
{:else if view === 'tile'}
|
{:else if view === 'tile'}
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<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} />
|
<TripTile {trip} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<TripTable trips={tripList} />
|
<TripTable {trips} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -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
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,73 +1,12 @@
|
|||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import { db } from '$lib/server/db/index.js';
|
|
||||||
|
|
||||||
const getAdminIdentifiers = (): string[] =>
|
export function requireAdmin(userId: string | undefined): void {
|
||||||
(process.env.ADMIN_USER_IDS ?? env.ADMIN_USER_IDS ?? '')
|
if (!userId) throw new Error('Not authenticated');
|
||||||
|
const adminIds = (env.ADMIN_USER_IDS ?? '')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
if (adminIds.length === 0 || !adminIds.includes(userId)) {
|
||||||
type AdminPrincipal =
|
|
||||||
| string
|
|
||||||
| {
|
|
||||||
id?: string | null;
|
|
||||||
name?: string | null;
|
|
||||||
email?: string | null;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
const normalize = (value: string): string => value.trim().toLowerCase();
|
|
||||||
|
|
||||||
const getPrincipalValues = (principal: AdminPrincipal): { userId?: string; values: string[] } => {
|
|
||||||
if (!principal) return { values: [] };
|
|
||||||
if (typeof principal === 'string') {
|
|
||||||
const value = principal.trim();
|
|
||||||
return value ? { userId: value, values: [value] } : { values: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const values = [principal.id, principal.name, principal.email]
|
|
||||||
.filter((value): value is string => Boolean(value?.trim()))
|
|
||||||
.map((value) => value.trim());
|
|
||||||
const localPart = principal.email?.split('@')[0]?.trim();
|
|
||||||
if (localPart) values.push(localPart);
|
|
||||||
|
|
||||||
const userId = principal.id?.trim();
|
|
||||||
return { userId, values };
|
|
||||||
};
|
|
||||||
|
|
||||||
export function isAdminUser(principal: AdminPrincipal): boolean {
|
|
||||||
const { userId, values } = getPrincipalValues(principal);
|
|
||||||
if (values.length === 0) return false;
|
|
||||||
|
|
||||||
const adminIds = getAdminIdentifiers();
|
|
||||||
if (adminIds.length === 0) return false;
|
|
||||||
|
|
||||||
const normalizedAdminIds = new Set(adminIds.map(normalize));
|
|
||||||
for (const value of values) {
|
|
||||||
if (adminIds.includes(value) || normalizedAdminIds.has(normalize(value))) return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!userId) return false;
|
|
||||||
|
|
||||||
const user = db.get<{ username: string; email: string | null }>(
|
|
||||||
'SELECT username, email FROM users WHERE id = ?',
|
|
||||||
[userId]
|
|
||||||
);
|
|
||||||
if (!user) return false;
|
|
||||||
|
|
||||||
const dbValues = [user.username, user.email, user.email?.split('@')[0]]
|
|
||||||
.filter((value): value is string => Boolean(value?.trim()))
|
|
||||||
.map((value) => value.trim());
|
|
||||||
for (const value of dbValues) {
|
|
||||||
if (adminIds.includes(value) || normalizedAdminIds.has(normalize(value))) return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function requireAdmin(principal: AdminPrincipal): void {
|
|
||||||
if (!principal) throw new Error('Not authenticated');
|
|
||||||
if (!isAdminUser(principal)) {
|
|
||||||
throw new Error('Admin access required');
|
throw new Error('Admin access required');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS cities (
|
CREATE TABLE IF NOT EXISTS cities (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
@@ -600,21 +579,14 @@ function seedCities(db: Database): void {
|
|||||||
const cities: { name: string; country: string; country_code: string; population: number }[] =
|
const cities: { name: string; country: string; country_code: string; population: number }[] =
|
||||||
JSON.parse(data);
|
JSON.parse(data);
|
||||||
let id = 1;
|
let id = 1;
|
||||||
db.run('BEGIN');
|
|
||||||
for (const city of cities) {
|
for (const city of cities) {
|
||||||
db.run(
|
db.run(
|
||||||
'INSERT OR IGNORE INTO cities (id, name, country, country_code, population) VALUES (?, ?, ?, ?, ?)',
|
'INSERT OR IGNORE INTO cities (id, name, country, country_code, population) VALUES (?, ?, ?, ?, ?)',
|
||||||
[id++, city.name, city.country, city.country_code, city.population]
|
[id++, city.name, city.country, city.country_code, city.population]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
db.run('COMMIT');
|
|
||||||
console.log(`[db] Seeded ${cities.length} cities`);
|
console.log(`[db] Seeded ${cities.length} cities`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
try {
|
|
||||||
db.run('ROLLBACK');
|
|
||||||
} catch {
|
|
||||||
/* ignore rollback failure */
|
|
||||||
}
|
|
||||||
console.error('[db] Failed to seed cities:', e);
|
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());
|
const lines = data.split('\n').filter((line) => line.trim());
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
db.run('BEGIN');
|
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
// Skip comments
|
// Skip comments
|
||||||
@@ -684,15 +655,9 @@ function seedAirports(db: Database): void {
|
|||||||
skipped++;
|
skipped++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
db.run('COMMIT');
|
|
||||||
console.log(`[db] Seeded ${imported} airports from airports.dat (${skipped} skipped)`);
|
console.log(`[db] Seeded ${imported} airports from airports.dat (${skipped} skipped)`);
|
||||||
return;
|
return;
|
||||||
} catch (_fileError) {
|
} catch (_fileError) {
|
||||||
try {
|
|
||||||
db.run('ROLLBACK');
|
|
||||||
} catch {
|
|
||||||
/* ignore rollback failure */
|
|
||||||
}
|
|
||||||
// File doesn't exist, fall back to minimal seed only if table is empty
|
// 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');
|
const airportsRow = db.get<{ count: number }>('SELECT COUNT(*) as count FROM airports');
|
||||||
if ((airportsRow?.count ?? 0) === 0) {
|
if ((airportsRow?.count ?? 0) === 0) {
|
||||||
@@ -796,7 +761,6 @@ function seedAirports(db: Database): void {
|
|||||||
];
|
];
|
||||||
|
|
||||||
let id = 1;
|
let id = 1;
|
||||||
db.run('BEGIN');
|
|
||||||
for (const airport of majorAirports) {
|
for (const airport of majorAirports) {
|
||||||
db.run(
|
db.run(
|
||||||
`INSERT OR IGNORE INTO airports (id, iata_code, icao_code, name, city, country, country_code, latitude, longitude, timezone)
|
`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`);
|
console.log(`[db] Seeded ${majorAirports.length} airports`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
try {
|
|
||||||
db.run('ROLLBACK');
|
|
||||||
} catch {
|
|
||||||
/* ignore rollback failure */
|
|
||||||
}
|
|
||||||
console.error('[db] Failed to seed airports:', e);
|
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());
|
const lines = data.split('\n').filter((line) => line.trim());
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
db.run('BEGIN');
|
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
// Skip comments
|
// Skip comments
|
||||||
@@ -879,15 +836,9 @@ function seedAirlines(db: Database): void {
|
|||||||
skipped++;
|
skipped++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
db.run('COMMIT');
|
|
||||||
console.log(`[db] Seeded ${imported} airlines from airlines.dat (${skipped} skipped)`);
|
console.log(`[db] Seeded ${imported} airlines from airlines.dat (${skipped} skipped)`);
|
||||||
return;
|
return;
|
||||||
} catch (_fileError) {
|
} catch (_fileError) {
|
||||||
try {
|
|
||||||
db.run('ROLLBACK');
|
|
||||||
} catch {
|
|
||||||
/* ignore rollback failure */
|
|
||||||
}
|
|
||||||
// File doesn't exist, fall back to minimal seed only if table is empty
|
// 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');
|
const airlinesRow = db.get<{ count: number }>('SELECT COUNT(*) as count FROM airlines');
|
||||||
if ((airlinesRow?.count ?? 0) === 0) {
|
if ((airlinesRow?.count ?? 0) === 0) {
|
||||||
@@ -949,7 +900,6 @@ function seedAirlines(db: Database): void {
|
|||||||
];
|
];
|
||||||
|
|
||||||
let id = 1;
|
let id = 1;
|
||||||
db.run('BEGIN');
|
|
||||||
for (const airline of majorAirlines) {
|
for (const airline of majorAirlines) {
|
||||||
db.run(
|
db.run(
|
||||||
`INSERT OR IGNORE INTO airlines (id, iata_code, icao_code, name, country, country_code)
|
`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]
|
[id++, airline.iata, airline.icao, airline.name, airline.country, airline.country_code]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
db.run('COMMIT');
|
|
||||||
console.log(`[db] Seeded ${majorAirlines.length} airlines`);
|
console.log(`[db] Seeded ${majorAirlines.length} airlines`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
try {
|
|
||||||
db.run('ROLLBACK');
|
|
||||||
} catch {
|
|
||||||
/* ignore rollback failure */
|
|
||||||
}
|
|
||||||
console.error('[db] Failed to seed airlines:', e);
|
console.error('[db] Failed to seed airlines:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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' };
|
|
||||||
}
|
|
||||||
@@ -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]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { db } from './db/index.js';
|
import { db } from './db/index.js';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { upsertUserProfile } from './users.js';
|
|
||||||
|
|
||||||
export interface Person {
|
export interface Person {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -34,7 +33,6 @@ export function upsertSelfProfile(
|
|||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
[firstName, lastName, email ?? null, existing.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])!;
|
return db.get<Person>('SELECT * FROM people WHERE id = ?', [existing.id])!;
|
||||||
}
|
}
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
@@ -43,7 +41,6 @@ export function upsertSelfProfile(
|
|||||||
VALUES (?, ?, ?, ?, ?, 1)`,
|
VALUES (?, ?, ?, ?, ?, 1)`,
|
||||||
[id, userId, firstName, lastName, email ?? null]
|
[id, userId, firstName, lastName, email ?? null]
|
||||||
);
|
);
|
||||||
upsertUserProfile(userId, `${firstName} ${lastName}`.trim(), email ?? null);
|
|
||||||
return db.get<Person>('SELECT * FROM people WHERE id = ?', [id])!;
|
return db.get<Person>('SELECT * FROM people WHERE id = ?', [id])!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
import { isAdminUser } from '$lib/server/admin/auth.js';
|
import { env } from '$env/dynamic/private';
|
||||||
import type { LayoutServerLoad } from './$types';
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async (event) => {
|
export const load: LayoutServerLoad = async (event) => {
|
||||||
@@ -9,7 +9,11 @@ export const load: LayoutServerLoad = async (event) => {
|
|||||||
redirect(303, `${base}/login`);
|
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 };
|
return { session, isAdmin };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { requireAdmin } from '$lib/server/admin/auth.js';
|
|||||||
import * as data from '$lib/server/admin/data.js';
|
import * as data from '$lib/server/admin/data.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = async (event) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user);
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
const q = event.url.searchParams.get('q') ?? undefined;
|
const q = event.url.searchParams.get('q') ?? undefined;
|
||||||
return json(data.listAirlines(q));
|
return json(data.listAirlines(q));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
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 body = await event.request.json();
|
||||||
const { name, country, country_code, iata_code, icao_code } = body as {
|
const { name, country, country_code, iata_code, icao_code } = body as {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -32,7 +32,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PATCH: 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 body = await event.request.json();
|
||||||
const { id, name, country, country_code, iata_code, icao_code } = body as {
|
const { id, name, country, country_code, iata_code, icao_code } = body as {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -55,7 +55,7 @@ export const PATCH: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: 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') ?? '');
|
const id = parseInt(event.url.searchParams.get('id') ?? '');
|
||||||
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
||||||
data.deleteAirline(id);
|
data.deleteAirline(id);
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { requireAdmin } from '$lib/server/admin/auth.js';
|
|||||||
import * as data from '$lib/server/admin/data.js';
|
import * as data from '$lib/server/admin/data.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = async (event) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user);
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
const q = event.url.searchParams.get('q') ?? undefined;
|
const q = event.url.searchParams.get('q') ?? undefined;
|
||||||
return json(data.listAirports(q));
|
return json(data.listAirports(q));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
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 body = await event.request.json();
|
||||||
const { name, country, country_code, iata_code, icao_code, city, latitude, longitude, timezone } =
|
const { name, country, country_code, iata_code, icao_code, city, latitude, longitude, timezone } =
|
||||||
body as {
|
body as {
|
||||||
@@ -43,7 +43,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PATCH: 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 body = await event.request.json();
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
@@ -87,7 +87,7 @@ export const PATCH: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: 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') ?? '');
|
const id = parseInt(event.url.searchParams.get('id') ?? '');
|
||||||
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
||||||
data.deleteAirport(id);
|
data.deleteAirport(id);
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import { requireAdmin } from '$lib/server/admin/auth.js';
|
|||||||
import * as data from '$lib/server/admin/data.js';
|
import * as data from '$lib/server/admin/data.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = async (event) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user);
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
const q = event.url.searchParams.get('q') ?? undefined;
|
const q = event.url.searchParams.get('q') ?? undefined;
|
||||||
const countryCode = event.url.searchParams.get('country_code') ?? undefined;
|
const countryCode = event.url.searchParams.get('country_code') ?? undefined;
|
||||||
return json(data.listCities(q, countryCode));
|
return json(data.listCities(q, countryCode));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
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 body = await event.request.json();
|
||||||
const { name, country, country_code, population } = body as {
|
const { name, country, country_code, population } = body as {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -26,7 +26,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PATCH: 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 body = await event.request.json();
|
||||||
const { id, name, country, country_code, population } = body as {
|
const { id, name, country, country_code, population } = body as {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -43,7 +43,7 @@ export const PATCH: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: 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') ?? '');
|
const id = parseInt(event.url.searchParams.get('id') ?? '');
|
||||||
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
||||||
data.deleteCity(id);
|
data.deleteCity(id);
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { requireAdmin } from '$lib/server/admin/auth.js';
|
|||||||
import * as data from '$lib/server/admin/data.js';
|
import * as data from '$lib/server/admin/data.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = async (event) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user);
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
const q = event.url.searchParams.get('q') ?? undefined;
|
const q = event.url.searchParams.get('q') ?? undefined;
|
||||||
return json(data.listCountries(q));
|
return json(data.listCountries(q));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
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 body = await event.request.json();
|
||||||
const { name, country_code } = body as { name?: string; country_code?: string };
|
const { name, country_code } = body as { name?: string; country_code?: string };
|
||||||
if (!name?.trim() || !country_code?.trim()) {
|
if (!name?.trim() || !country_code?.trim()) {
|
||||||
@@ -20,7 +20,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PATCH: 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 body = await event.request.json();
|
||||||
const { id, name, country_code } = body as { id?: number; name?: string; country_code?: string };
|
const { id, name, country_code } = body as { id?: number; name?: string; country_code?: string };
|
||||||
if (id == null || !name?.trim() || !country_code?.trim()) {
|
if (id == null || !name?.trim() || !country_code?.trim()) {
|
||||||
@@ -31,7 +31,7 @@ export const PATCH: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: 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') ?? '');
|
const id = parseInt(event.url.searchParams.get('id') ?? '');
|
||||||
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
||||||
data.deleteCountry(id);
|
data.deleteCountry(id);
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import { requireAdmin } from '$lib/server/admin/auth.js';
|
|||||||
import * as data from '$lib/server/admin/data.js';
|
import * as data from '$lib/server/admin/data.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = async (event) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user);
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
const tourId = parseInt(event.url.searchParams.get('operator_tour_id') ?? '');
|
const tourId = parseInt(event.url.searchParams.get('operator_tour_id') ?? '');
|
||||||
if (isNaN(tourId)) return json({ error: 'operator_tour_id required' }, { status: 400 });
|
if (isNaN(tourId)) return json({ error: 'operator_tour_id required' }, { status: 400 });
|
||||||
return json(data.listDaysForOperatorTour(tourId));
|
return json(data.listDaysForOperatorTour(tourId));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
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 body = await event.request.json();
|
||||||
const { operator_tour_id, title, notes } = body as {
|
const { operator_tour_id, title, notes } = body as {
|
||||||
operator_tour_id?: number;
|
operator_tour_id?: number;
|
||||||
@@ -25,7 +25,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PATCH: 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 body = await event.request.json();
|
||||||
const { id, title, notes } = body as { id?: number; title?: string; notes?: string };
|
const { id, title, notes } = body as { id?: number; title?: string; notes?: string };
|
||||||
if (id == null) return json({ error: 'id is required' }, { status: 400 });
|
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) => {
|
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') ?? '');
|
const id = parseInt(event.url.searchParams.get('id') ?? '');
|
||||||
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
||||||
data.deleteOperatorTourDay(id);
|
data.deleteOperatorTourDay(id);
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import { requireAdmin } from '$lib/server/admin/auth.js';
|
|||||||
import * as data from '$lib/server/admin/data.js';
|
import * as data from '$lib/server/admin/data.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = async (event) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user);
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
const operatorId = parseInt(event.url.searchParams.get('operator_id') ?? '');
|
const operatorId = parseInt(event.url.searchParams.get('operator_id') ?? '');
|
||||||
if (isNaN(operatorId)) return json({ error: 'operator_id required' }, { status: 400 });
|
if (isNaN(operatorId)) return json({ error: 'operator_id required' }, { status: 400 });
|
||||||
return json(data.listToursForOperator(operatorId));
|
return json(data.listToursForOperator(operatorId));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
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 body = await event.request.json();
|
||||||
const { operator_id, name } = body as { operator_id?: number; name?: string };
|
const { operator_id, name } = body as { operator_id?: number; name?: string };
|
||||||
if (operator_id == null || !name?.trim()) {
|
if (operator_id == null || !name?.trim()) {
|
||||||
@@ -21,7 +21,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PATCH: 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 body = await event.request.json();
|
||||||
const { id, name } = body as { id?: number; name?: string };
|
const { id, name } = body as { id?: number; name?: string };
|
||||||
if (id == null || !name?.trim()) {
|
if (id == null || !name?.trim()) {
|
||||||
@@ -32,7 +32,7 @@ export const PATCH: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: 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') ?? '');
|
const id = parseInt(event.url.searchParams.get('id') ?? '');
|
||||||
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
||||||
data.deleteOperatorTour(id);
|
data.deleteOperatorTour(id);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { listTourOperators } from '$lib/server/admin/data.js';
|
|||||||
import { getProviderForOperator } from '$lib/server/admin/providers/index.js';
|
import { getProviderForOperator } from '$lib/server/admin/providers/index.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = async (event) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user);
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
|
||||||
const operatorId = parseInt(event.url.searchParams.get('operator_id') ?? '');
|
const operatorId = parseInt(event.url.searchParams.get('operator_id') ?? '');
|
||||||
if (isNaN(operatorId)) return json({ error: 'operator_id required' }, { status: 400 });
|
if (isNaN(operatorId)) return json({ error: 'operator_id required' }, { status: 400 });
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { requireAdmin } from '$lib/server/admin/auth.js';
|
|||||||
import * as data from '$lib/server/admin/data.js';
|
import * as data from '$lib/server/admin/data.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = async (event) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user);
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
const q = event.url.searchParams.get('q') ?? undefined;
|
const q = event.url.searchParams.get('q') ?? undefined;
|
||||||
return json(data.listTourOperators(q));
|
return json(data.listTourOperators(q));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
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 body = await event.request.json();
|
||||||
const { name, website, highlight_color } = body as {
|
const { name, website, highlight_color } = body as {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -24,7 +24,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PATCH: 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 body = await event.request.json();
|
||||||
const { id, name, website, highlight_color } = body as {
|
const { id, name, website, highlight_color } = body as {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -40,7 +40,7 @@ export const PATCH: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: 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') ?? '');
|
const id = parseInt(event.url.searchParams.get('id') ?? '');
|
||||||
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
||||||
data.deleteTourOperator(id);
|
data.deleteTourOperator(id);
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
};
|
|
||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -5,7 +5,7 @@ import { getProviderForOperator } from '$lib/server/admin/providers/index.js';
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async (event) => {
|
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 operatorId = parseInt(event.params.operatorId);
|
||||||
if (isNaN(operatorId)) error(404, 'Not found');
|
if (isNaN(operatorId)) error(404, 'Not found');
|
||||||
@@ -27,7 +27,7 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
addTour: async (event) => {
|
addTour: async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user);
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
|
||||||
const operatorId = parseInt(event.params.operatorId);
|
const operatorId = parseInt(event.params.operatorId);
|
||||||
if (isNaN(operatorId)) return fail(400, { error: 'Invalid operator ID' });
|
if (isNaN(operatorId)) return fail(400, { error: 'Invalid operator ID' });
|
||||||
@@ -45,7 +45,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
editTour: async (event) => {
|
editTour: async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user);
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
|
||||||
const formData = await event.request.formData();
|
const formData = await event.request.formData();
|
||||||
const id = parseInt(formData.get('id') as string);
|
const id = parseInt(formData.get('id') as string);
|
||||||
@@ -63,7 +63,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteTour: async (event) => {
|
deleteTour: async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user);
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
|
||||||
const formData = await event.request.formData();
|
const formData = await event.request.formData();
|
||||||
const id = parseInt(formData.get('id') as string);
|
const id = parseInt(formData.get('id') as string);
|
||||||
@@ -78,7 +78,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
importTour: async (event) => {
|
importTour: async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user);
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
|
||||||
const operatorId = parseInt(event.params.operatorId);
|
const operatorId = parseInt(event.params.operatorId);
|
||||||
if (isNaN(operatorId)) return fail(400, { error: 'Invalid operator ID' });
|
if (isNaN(operatorId)) return fail(400, { error: 'Invalid operator ID' });
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as data from '$lib/server/admin/data.js';
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async (event) => {
|
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 operatorId = parseInt(event.params.operatorId);
|
||||||
const tourId = parseInt(event.params.tourId);
|
const tourId = parseInt(event.params.tourId);
|
||||||
@@ -27,7 +27,7 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
addDay: async (event) => {
|
addDay: async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user);
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
|
||||||
const tourId = parseInt(event.params.tourId);
|
const tourId = parseInt(event.params.tourId);
|
||||||
if (isNaN(tourId)) return fail(400, { error: 'Invalid tour ID' });
|
if (isNaN(tourId)) return fail(400, { error: 'Invalid tour ID' });
|
||||||
@@ -45,7 +45,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
editDay: async (event) => {
|
editDay: async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user);
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
|
||||||
const formData = await event.request.formData();
|
const formData = await event.request.formData();
|
||||||
const id = parseInt(formData.get('id') as string);
|
const id = parseInt(formData.get('id') as string);
|
||||||
@@ -63,7 +63,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteDay: async (event) => {
|
deleteDay: async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user);
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
|
||||||
const formData = await event.request.formData();
|
const formData = await event.request.formData();
|
||||||
const id = parseInt(formData.get('id') as string);
|
const id = parseInt(formData.get('id') as string);
|
||||||
@@ -78,7 +78,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
addDayPlan: async (event) => {
|
addDayPlan: async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user);
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
|
||||||
const formData = await event.request.formData();
|
const formData = await event.request.formData();
|
||||||
const dayId = parseInt(formData.get('dayId') as string);
|
const dayId = parseInt(formData.get('dayId') as string);
|
||||||
@@ -247,7 +247,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
editDayPlan: async (event) => {
|
editDayPlan: async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user);
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
|
||||||
const formData = await event.request.formData();
|
const formData = await event.request.formData();
|
||||||
const id = parseInt(formData.get('id') as string);
|
const id = parseInt(formData.get('id') as string);
|
||||||
@@ -310,7 +310,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteDayPlan: async (event) => {
|
deleteDayPlan: async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user);
|
requireAdmin((await event.locals.auth())?.user?.id);
|
||||||
|
|
||||||
const formData = await event.request.formData();
|
const formData = await event.request.formData();
|
||||||
const id = parseInt(formData.get('id') as string);
|
const id = parseInt(formData.get('id') as string);
|
||||||
|
|||||||
@@ -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}
|
|
||||||
@@ -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)
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,423 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { base } from '$app/paths';
|
|
||||||
import type { DashboardData, DashboardMapStop } from '$lib/server/dashboard.js';
|
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
const dashboard = $derived(data.dashboard) as DashboardData;
|
|
||||||
|
|
||||||
const mapLongitudeOffset = 12;
|
|
||||||
const markers = $derived(
|
|
||||||
dashboard.mapStops.map((stop: DashboardMapStop, index: number) => {
|
|
||||||
const x = ((stop.lon + 180 - mapLongitudeOffset) / 360) * 100;
|
|
||||||
const y = ((90 - stop.lat) / 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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Dashboard — Trips</title>
|
<title>Dashboard — Trips</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="dashboard-shell space-y-8">
|
<h1 class="mb-2 text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||||
<header class="rounded-3xl bg-white p-6 shadow-sm">
|
<p class="text-gray-600">Welcome, {data.session.user?.name ?? data.session.user?.email}.</p>
|
||||||
<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}. Here’s 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 & future routes</h2>
|
|
||||||
<p class="mt-2 text-sm text-slate-200/80">
|
|
||||||
Pins highlight places from your journey log and what’s 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 you’ve 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>
|
|
||||||
|
|||||||
@@ -1,22 +1,8 @@
|
|||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
const resolveErrorMessage = (value: string | null): string | null => {
|
export const load: PageServerLoad = async () => {
|
||||||
if (!value) return null;
|
|
||||||
if (value === 'CredentialsSignin') return 'Invalid credentials';
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url }) => {
|
|
||||||
const authUrl = process.env.AUTH_URL ?? env.AUTH_URL ?? '';
|
|
||||||
const localAuthEnabled = (process.env.LOCAL_AUTH_ENABLED ?? env.LOCAL_AUTH_ENABLED) === 'true';
|
|
||||||
const appRoot = authUrl.replace(/\/auth$/, '');
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
signinUrl: `${authUrl}/signin/synology`,
|
signinUrl: `${env.AUTH_URL}/signin/synology`
|
||||||
localSigninUrl: `${authUrl}/callback/local`,
|
|
||||||
callbackUrl: appRoot,
|
|
||||||
localAuthEnabled,
|
|
||||||
error: resolveErrorMessage(url.searchParams.get('error'))
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,86 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
let form: HTMLFormElement;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Sign in — Trips</title>
|
<title>Sign in — Trips</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="mx-auto flex min-h-[70vh] w-full max-w-3xl flex-col justify-center px-6 py-12">
|
<form bind:this={form} method="POST" action={data.signinUrl} class="hidden">
|
||||||
<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>
|
|
||||||
|
|
||||||
{#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" />
|
<input type="hidden" name="csrfToken" />
|
||||||
<button
|
</form>
|
||||||
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">
|
<div class="flex min-h-[60vh] items-center justify-center">
|
||||||
<h2 class="text-lg font-semibold text-slate-900">Local account</h2>
|
<p class="text-gray-500">Redirecting to sign in…</p>
|
||||||
<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>
|
</div>
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 |
@@ -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 |
@@ -1,33 +1,18 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 61">
|
||||||
<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">
|
<title>LATAM Airlines Chile</title>
|
||||||
<metadata id="metadata26394">
|
<desc>LATAM Airlines Chile logo</desc>
|
||||||
<rdf:RDF>
|
<metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:Airline="http://schema.org/Airline">
|
||||||
<cc:Work rdf:about="">
|
<rdf:RDF>
|
||||||
<dc:format>image/svg+xml</dc:format>
|
<Airline:name>LATAM Airlines Chile</Airline:name>
|
||||||
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
|
<Airline:alternateName>LATAM Airlines</Airline:alternateName>
|
||||||
<dc:title/>
|
<Airline:iataCode>LA</Airline:iataCode>
|
||||||
</cc:Work>
|
<Airline:url>https://airlinelogos.aero/LA</Airline:url>
|
||||||
</rdf:RDF>
|
</rdf:RDF>
|
||||||
</metadata>
|
</metadata>
|
||||||
<g id="layer5" fill-rule="evenodd" transform="translate(580 -582.36)">
|
<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="#2a0088">
|
<g fill="#ed1650">
|
||||||
<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 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 id="path27065" d="m729.66 742.36v61.834l148.82 10v238.17h85.953v-238.17l148.82-10v-61.834z"/>
|
<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"/>
|
||||||
<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"/>
|
</g>
|
||||||
<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>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -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 |
@@ -1,33 +1,18 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 61">
|
||||||
<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">
|
<title>LATAM Airlines Paraguay</title>
|
||||||
<metadata id="metadata26394">
|
<desc>LATAM Airlines Paraguay logo</desc>
|
||||||
<rdf:RDF>
|
<metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:Airline="http://schema.org/Airline">
|
||||||
<cc:Work rdf:about="">
|
<rdf:RDF>
|
||||||
<dc:format>image/svg+xml</dc:format>
|
<Airline:name>LATAM Airlines Paraguay</Airline:name>
|
||||||
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
|
<Airline:alternateName>LATAM Airlines</Airline:alternateName>
|
||||||
<dc:title/>
|
<Airline:iataCode>PZ</Airline:iataCode>
|
||||||
</cc:Work>
|
<Airline:url>https://airlinelogos.aero/PZ</Airline:url>
|
||||||
</rdf:RDF>
|
</rdf:RDF>
|
||||||
</metadata>
|
</metadata>
|
||||||
<g id="layer5" fill-rule="evenodd" transform="translate(580 -582.36)">
|
<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="#2a0088">
|
<g fill="#ed1650">
|
||||||
<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 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 id="path27065" d="m729.66 742.36v61.834l148.82 10v238.17h85.953v-238.17l148.82-10v-61.834z"/>
|
<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"/>
|
||||||
<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"/>
|
</g>
|
||||||
<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>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -1,33 +1,18 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 61">
|
||||||
<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">
|
<title>LATAM Airlines Ecuador</title>
|
||||||
<metadata id="metadata26394">
|
<desc>LATAM Airlines Ecuador logo</desc>
|
||||||
<rdf:RDF>
|
<metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:Airline="http://schema.org/Airline">
|
||||||
<cc:Work rdf:about="">
|
<rdf:RDF>
|
||||||
<dc:format>image/svg+xml</dc:format>
|
<Airline:name>LATAM Airlines Ecuador</Airline:name>
|
||||||
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
|
<Airline:alternateName>LATAM Airlines</Airline:alternateName>
|
||||||
<dc:title/>
|
<Airline:iataCode>XL</Airline:iataCode>
|
||||||
</cc:Work>
|
<Airline:url>https://airlinelogos.aero/XL</Airline:url>
|
||||||
</rdf:RDF>
|
</rdf:RDF>
|
||||||
</metadata>
|
</metadata>
|
||||||
<g id="layer5" fill-rule="evenodd" transform="translate(580 -582.36)">
|
<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="#2a0088">
|
<g fill="#ed1650">
|
||||||
<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 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 id="path27065" d="m729.66 742.36v61.834l148.82 10v238.17h85.953v-238.17l148.82-10v-61.834z"/>
|
<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"/>
|
||||||
<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"/>
|
</g>
|
||||||
<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>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 83 KiB |
@@ -5,6 +5,6 @@ import { defineConfig } from 'vite';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss(), sveltekit()],
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
server: {
|
server: {
|
||||||
allowedHosts: ['cloud.campbellwireless.net', '127.0.0.1', 'localhost']
|
allowedHosts: ['cloud.campbellwireless.net']
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||