11 Commits

Author SHA1 Message Date
ebc8f80bb5 trips) add dashboard overview (#54)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m14s
Reviewed-on: #54
Co-authored-by: AI Agent <ai-agent@campbellwireless.net>
Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
2026-02-24 21:23:54 +00:00
4aabf47108 trip) add day/week trip views (#52)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m19s
Reviewed-on: #52
Co-authored-by: AI Agent <ai-agent@campbellwireless.net>
Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
2026-02-24 20:17:26 +00:00
2003a1aada Add LATAM airline icons (#50)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m22s
Reviewed-on: #50
Co-authored-by: AI Agent <ai-agent@campbellwireless.net>
Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
2026-02-24 17:58:23 +00:00
ae39f7961a trip) add timeline view to trip page (#48)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m21s
Co-authored-by: Shaun Campbell <shaun@campbellwireless.net>
Reviewed-on: #48
Co-authored-by: AI Agent <ai-agent@campbellwireless.net>
Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
2026-02-23 03:17:44 +00:00
d43889c7b0 trips) add low-priority e2e smoke coverage (#46)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m28s
Reviewed-on: #46
Co-authored-by: AI Agent <ai-agent@campbellwireless.net>
Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
2026-02-23 00:48:21 +00:00
f75465e7f1 e2e: add medium-priority Playwright coverage (#44)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m29s
Co-authored-by: Shaun Campbell <shaun@campbellwireless.net>
Reviewed-on: #44
Co-authored-by: AI Agent <ai-agent@campbellwireless.net>
Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
2026-02-23 00:12:24 +00:00
224ca77a9d Add high-priority Playwright trips coverage (#42)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m32s
Co-authored-by: Shaun Campbell <shaun@campbellwireless.net>
Reviewed-on: #42
Co-authored-by: AI Agent <ai-agent@campbellwireless.net>
Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
2026-02-22 22:30:44 +00:00
fe289f0895 Add Playwright e2e coverage + comment-level video upload workflow (#40)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m31s
## Summary
- add Playwright e2e auth setup + seeded test DB flow
- fix Playwright startup order so DB seed runs before web server uses SQLite
- add full trip-planning e2e scenario (future trip, travellers, destination, flight, lodging, upcoming verification)
- configure Auth.js custom sign-in page for base-path routing
- add repo guidance in AGENTS.md / CLAUDE.md requiring e2e for user-facing changes
- document and validate comment-level Gitea video attachment workflow (create comment, upload to comment assets endpoint)

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

## Issue
- relates to #38

Co-authored-by: AI Agent <ai-agent@campbellwireless.net>
Reviewed-on: #40
Co-authored-by: Shaun Campbell <shaun@campbellwireless.net>
Co-committed-by: Shaun Campbell <shaun@campbellwireless.net>
2026-02-22 21:20:19 +00:00
e11fcb56a2 admin) allow local login user management (#39)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m28s
Co-authored-by: Shaun Campbell <shaun@campbellwireless.net>
Reviewed-on: #39
Co-authored-by: AI Agent <ai-agent@campbellwireless.net>
Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
2026-02-22 20:07:46 +00:00
fc8e80186d Add local authentication option (#37)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m20s
Co-authored-by: Shaun Campbell <shaun@campbellwireless.net>
Reviewed-on: #37
Co-authored-by: AI Agent <ai-agent@campbellwireless.net>
Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
2026-02-22 05:17:45 +00:00
42fe36ca86 admin) User management admin page (#35)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m20s
Reviewed-on: #35
Reviewed-by: shaun <shaun@campbellwireless.net>
Co-authored-by: AI Agent <ai-agent@campbellwireless.net>
Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
2026-02-22 04:26:16 +00:00
75 changed files with 6570 additions and 489 deletions

View File

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

21
.env.test Normal file
View File

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

View File

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

View File

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

View File

@@ -1,11 +0,0 @@
Hello and welcome to the trips project!
We are glad you are here. This repository exists to help you build and
improve travel experiences, and your contributions make it better for
everyone.
If you are new, start by reading the README and checking the scripts and
configuration files to understand how the project is set up. Then pick an
issue or improvement you are interested in and dive in.
Thanks for being part of the team and happy coding!

View File

@@ -43,6 +43,19 @@ bun install
bun run dev
```
## Local authentication
Local auth is optional and off by default. Enable it with the env vars in `.env.example` and ensure users have a matching row in both `users` and `local_credentials`.
To seed a local password hash, use Argon2id with the configured parameters and insert it into `local_credentials`:
```sql
INSERT INTO local_credentials (user_id, password_hash)
VALUES ('<user-id>', '<argon2id hash>');
```
Passwords must be at least 12 characters. Avoid storing plaintext passwords anywhere.
## Quality and Tests
```sh

View File

@@ -6,16 +6,19 @@
"name": "trips",
"dependencies": {
"@auth/sveltekit": "^1.11.1",
"argon2": "^0.41.1",
},
"devDependencies": {
"@biomejs/biome": "^2.4.4",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.58.2",
"@sveltejs/adapter-node": "^5.5.3",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.0",
"@vitest/coverage-v8": "^4.0.18",
"bun-types": "^1.3.9",
"dotenv": "^17.3.1",
"eslint": "^10.0.0",
"eslint-plugin-svelte": "^3.15.0",
"globals": "^17.3.0",
@@ -153,6 +156,10 @@
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
"@phc/format": ["@phc/format@1.0.0", "", {}, "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ=="],
"@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@29.0.0", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-U2YHaxR2cU/yAiwKJtJRhnyLk7cifnQw0zUpISsocBDoHDJn+HTV74ABqnwr5bEgWUwFZC9oFL6wLe21lHu5eQ=="],
@@ -315,6 +322,8 @@
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"argon2": ["argon2@0.41.1", "", { "dependencies": { "@phc/format": "^1.0.0", "node-addon-api": "^8.1.0", "node-gyp-build": "^4.8.1" } }, "sha512-dqCW8kJXke8Ik+McUcMDltrbuAWETPyU6iq+4AhxqKphWi7pChB/Zgd/Tp/o8xRLbg8ksMj46F/vph9wnxpTzQ=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
@@ -353,6 +362,8 @@
"devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="],
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
@@ -509,6 +520,10 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="],
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
"oauth4webapi": ["oauth4webapi@3.8.5", "", {}, "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
@@ -531,6 +546,10 @@
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-load-config": ["postcss-load-config@3.1.4", "", { "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg=="],
@@ -665,6 +684,8 @@
"eslint-plugin-svelte/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"svelte-eslint-parser/eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"svelte-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],

View File

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

View File

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

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

@@ -0,0 +1,25 @@
import { test, expect, type Page } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js';
const LOGIN_URL = '/trips/login';
const UPCOMING_URL = '/trips/trips/upcoming';
async function submitLocalLogin(page: Page, username: string, password: string): Promise<void> {
await page.fill('input[name="identifier"]', username);
await page.fill('input[name="password"]', password);
await page.click('button[type="submit"]:has-text("Sign in locally")');
}
test.beforeEach(async ({ context }) => {
await context.clearCookies();
});
test('redirects back to protected route after local login', async ({ page }) => {
await page.goto(UPCOMING_URL);
await expect(page).toHaveURL(/\/trips\/login/);
await submitLocalLogin(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await page.waitForURL(/\/trips\/(trips\/upcoming|dashboard)/, { timeout: 15_000 });
await expect(page).not.toHaveURL(/\/trips\/auth/);
await expect(page).not.toHaveURL(/\/trips\/login/);
});

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

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

View File

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

75
e2e/dashboard.test.ts Normal file
View File

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

View File

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

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

@@ -0,0 +1,27 @@
import { expect, type Page } from '@playwright/test';
import { TEST_USERS } from '../setup/test-users.js';
const LOGIN_URL = '/trips/login';
const DASHBOARD_URL = '/trips/dashboard';
const PROFILE_URL = '/trips/profile';
export async function loginAsLocalUser(
page: Page,
username: string,
password: string
): Promise<void> {
await page.goto(LOGIN_URL);
await page.fill('input[name="identifier"]', username);
await page.fill('input[name="password"]', password);
await page.click('button[type="submit"]:has-text("Sign in locally")');
await page.waitForURL(`**${DASHBOARD_URL}`, { timeout: 15_000 });
}
export async function ensureSelfProfile(page: Page, email = TEST_USERS.regular.email): Promise<void> {
await page.goto(PROFILE_URL);
await page.fill('#first_name', 'E2E');
await page.fill('#last_name', 'User');
await page.fill('#email', email);
await page.getByRole('button', { name: 'Save profile' }).click();
await expect(page.getByRole('heading', { name: 'My Profile' })).toBeVisible();
}

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

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

View File

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

View File

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

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

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

131
e2e/trip-timeline.test.ts Normal file
View File

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

View File

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

59
playwright.config.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

33
src/lib/date-views.ts Normal file
View File

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

View File

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

View File

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

192
src/lib/server/dashboard.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,195 @@
import { randomUUID } from 'crypto';
import { db } from './db/index.js';
import { setLocalCredentialPassword } from './local-credentials.js';
export interface AppUser {
id: string;
username: string;
full_name: string;
email: string | null;
auth_source: string;
created_at: string;
updated_at: string;
}
const DEFAULT_AUTH_SOURCE = 'OIDC - Synology';
const LOCAL_AUTH_SOURCE = 'Local';
const normalizeOptional = (value?: string | null): string | undefined => {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
};
const normalizeEmail = (value?: string | null): string | null | undefined => {
if (value === undefined) return undefined;
if (value === null) return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
};
export function listUsers(): (AppUser & { has_local_credentials: boolean })[] {
return db
.all<AppUser & { has_local_credentials: 0 | 1 }>(
`SELECT u.id, u.username, u.full_name, u.email, u.auth_source, u.created_at, u.updated_at,
CASE WHEN lc.user_id IS NOT NULL THEN 1 ELSE 0 END AS has_local_credentials
FROM users u
LEFT JOIN local_credentials lc ON lc.user_id = u.id
ORDER BY u.username COLLATE NOCASE`
)
.map((row) => ({ ...row, has_local_credentials: row.has_local_credentials === 1 }));
}
export function upsertUserFromAuth(input: {
id: string;
username?: string | null;
fullName?: string | null;
email?: string | null;
authSource?: string | null;
}): AppUser {
const existing = db.get<AppUser>('SELECT * FROM users WHERE id = ?', [input.id]);
const username = normalizeOptional(input.username) ?? existing?.username ?? input.id;
const fullName = normalizeOptional(input.fullName) ?? existing?.full_name ?? '';
const email = normalizeOptional(input.email) ?? existing?.email ?? null;
const authSource =
normalizeOptional(input.authSource) ?? existing?.auth_source ?? DEFAULT_AUTH_SOURCE;
if (existing) {
const setClauses: string[] = [];
const values: Array<string | null> = [];
if (username !== existing.username) {
setClauses.push('username = ?');
values.push(username);
}
if (fullName !== existing.full_name) {
setClauses.push('full_name = ?');
values.push(fullName);
}
if (email !== existing.email) {
setClauses.push('email = ?');
values.push(email);
}
if (authSource !== existing.auth_source) {
setClauses.push('auth_source = ?');
values.push(authSource);
}
if (setClauses.length > 0) {
setClauses.push("updated_at = datetime('now')");
values.push(input.id);
db.run(`UPDATE users SET ${setClauses.join(', ')} WHERE id = ?`, values);
}
} else {
db.run(
`INSERT INTO users (id, username, full_name, email, auth_source)
VALUES (?, ?, ?, ?, ?)`,
[input.id, username, fullName, email, authSource]
);
}
return db.get<AppUser>('SELECT * FROM users WHERE id = ?', [input.id])!;
}
export function upsertUserProfile(
userId: string,
fullName: string,
email?: string | null
): AppUser {
return upsertUserFromAuth({ id: userId, fullName, email, authSource: DEFAULT_AUTH_SOURCE });
}
export function updateUser(input: {
id: string;
username: string;
fullName: string;
email?: string | null;
}): void {
const existing = db.get<{ email: string | null }>('SELECT email FROM users WHERE id = ?', [
input.id
]);
const email = normalizeEmail(input.email);
const nextEmail = email === undefined ? existing?.email ?? null : email;
db.run(
`UPDATE users
SET username = ?, full_name = ?, email = ?, updated_at = datetime('now')
WHERE id = ?`,
[input.username.trim(), input.fullName.trim(), nextEmail, input.id]
);
syncSelfProfile(input.id, input.fullName, nextEmail);
}
export function deleteUser(id: string): void {
db.run('DELETE FROM users WHERE id = ?', [id]);
}
export async function createLocalUser(input: {
username: string;
fullName: string;
email?: string | null;
password: string;
}): Promise<AppUser> {
const username = input.username?.trim() ?? '';
const fullName = input.fullName?.trim() ?? '';
if (!username || !fullName) {
throw new Error('Username and full name are required');
}
const email = normalizeEmail(input.email);
const existingUsername = db.get<{ id: string }>(
'SELECT id FROM users WHERE lower(username) = lower(?)',
[username]
);
if (existingUsername) {
throw new Error('Username already exists');
}
if (email) {
const existingEmail = db.get<{ id: string }>(
'SELECT id FROM users WHERE lower(email) = lower(?)',
[email]
);
if (existingEmail) {
throw new Error('Email already exists');
}
}
const id = randomUUID();
db.run(
`INSERT INTO users (id, username, full_name, email, auth_source)
VALUES (?, ?, ?, ?, ?)`,
[id, username, fullName, email ?? null, LOCAL_AUTH_SOURCE]
);
await setLocalCredentialPassword(id, input.password);
return db.get<AppUser>('SELECT * FROM users WHERE id = ?', [id])!;
}
export async function setLocalPasswordForUser(userId: string, password: string): Promise<void> {
const id = userId?.trim() ?? '';
if (!id) {
throw new Error('User id required');
}
const existing = db.get<{ id: string }>('SELECT id FROM users WHERE id = ?', [id]);
if (!existing) {
throw new Error('User not found');
}
await setLocalCredentialPassword(id, password);
}
function syncSelfProfile(userId: string, fullName: string, email?: string | null): void {
const profile = db.get<{ id: string; first_name: string; last_name: string; email: string | null }>(
`SELECT id, first_name, last_name, email FROM people WHERE user_id = ? AND is_self = 1 LIMIT 1`,
[userId]
);
if (!profile) return;
const trimmed = fullName.trim();
let firstName = profile.first_name;
let lastName = profile.last_name;
if (trimmed) {
const parts = trimmed.split(/\s+/);
firstName = parts[0] ?? profile.first_name;
if (parts.length > 1) {
lastName = parts.slice(1).join(' ');
}
}
const nextEmail = email === undefined ? profile.email : email;
db.run(
`UPDATE people SET first_name = ?, last_name = ?, email = ?, updated_at = datetime('now')
WHERE id = ?`,
[firstName, lastName, nextEmail, profile.id]
);
}

295
src/lib/timeline.test.ts Normal file
View File

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

321
src/lib/timeline.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,597 @@
<script lang="ts">
import { base } from '$app/paths';
import { onMount } from 'svelte';
interface User {
id: string;
username: string;
full_name: string;
email: string | null;
auth_source: string;
has_local_credentials: boolean;
}
let users = $state<User[]>([]);
let loading = $state(true);
let error = $state('');
let editing = $state<User | null>(null);
let editUsername = $state('');
let editFullName = $state('');
let editEmail = $state('');
let addDrawerOpen = $state(false);
let addUsername = $state('');
let addFullName = $state('');
let addEmail = $state('');
let addPassword = $state('');
let addPasswordConfirm = $state('');
let addError = $state('');
let addSubmitting = $state(false);
let passwordDrawerUser = $state<User | null>(null);
let localPassword = $state('');
let localPasswordConfirm = $state('');
let localPasswordError = $state('');
let localPasswordSubmitting = $state(false);
async function loadUsers() {
loading = true;
try {
const res = await fetch(`${base}/admin/api/users`);
if (!res.ok) {
error = 'Failed to load users';
return;
}
users = await res.json();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load users';
} finally {
loading = false;
}
}
function startEdit(user: User) {
editing = user;
editUsername = user.username;
editFullName = user.full_name;
editEmail = user.email ?? '';
error = '';
}
function cancelEdit() {
editing = null;
editUsername = '';
editFullName = '';
editEmail = '';
error = '';
}
function openAddDrawer() {
addDrawerOpen = true;
addError = '';
}
function closeAddDrawer() {
addDrawerOpen = false;
addUsername = '';
addFullName = '';
addEmail = '';
addPassword = '';
addPasswordConfirm = '';
addError = '';
}
async function submitAddUser() {
if (!addUsername.trim() || !addFullName.trim() || !addPassword.trim()) {
addError = 'Username, full name, and password are required';
return;
}
if (addPassword.trim().length < 12) {
addError = 'Password must be at least 12 characters';
return;
}
if (addPassword.trim() !== addPasswordConfirm.trim()) {
addError = 'Passwords do not match';
return;
}
addSubmitting = true;
addError = '';
try {
const res = await fetch(`${base}/admin/api/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: addUsername.trim(),
full_name: addFullName.trim(),
email: addEmail.trim() || null,
password: addPassword
})
});
if (!res.ok) {
const data = await res.json();
addError = data.error ?? 'Failed to create user';
return;
}
closeAddDrawer();
loadUsers();
} catch (e) {
addError = e instanceof Error ? e.message : 'Failed to create user';
} finally {
addSubmitting = false;
}
}
function openPasswordDrawer(user: User) {
passwordDrawerUser = user;
localPassword = '';
localPasswordConfirm = '';
localPasswordError = '';
}
function closePasswordDrawer() {
passwordDrawerUser = null;
localPassword = '';
localPasswordConfirm = '';
localPasswordError = '';
}
async function submitLocalPassword() {
if (!passwordDrawerUser) return;
if (!localPassword.trim()) {
localPasswordError = 'Password is required';
return;
}
if (localPassword.trim().length < 12) {
localPasswordError = 'Password must be at least 12 characters';
return;
}
if (localPassword.trim() !== localPasswordConfirm.trim()) {
localPasswordError = 'Passwords do not match';
return;
}
localPasswordSubmitting = true;
localPasswordError = '';
try {
const res = await fetch(`${base}/admin/api/users/local-credentials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: passwordDrawerUser.id,
password: localPassword
})
});
if (!res.ok) {
const data = await res.json();
localPasswordError = data.error ?? 'Failed to set password';
return;
}
closePasswordDrawer();
} catch (e) {
localPasswordError = e instanceof Error ? e.message : 'Failed to set password';
} finally {
localPasswordSubmitting = false;
}
}
async function submitEdit() {
if (!editing) return;
if (!editUsername.trim() || !editFullName.trim()) {
error = 'Username and full name are required';
return;
}
try {
const res = await fetch(`${base}/admin/api/users`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: editing.id,
username: editUsername.trim(),
full_name: editFullName.trim(),
email: editEmail.trim() || null
})
});
if (!res.ok) {
const data = await res.json();
error = data.error ?? 'Failed to update user';
return;
}
cancelEdit();
loadUsers();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update user';
}
}
async function removeUser(user: User) {
if (!confirm(`Remove ${user.username}?`)) return;
try {
const res = await fetch(`${base}/admin/api/users?id=${encodeURIComponent(user.id)}`, {
method: 'DELETE'
});
if (!res.ok) {
const data = await res.json();
alert(data.error ?? 'Failed to remove user');
return;
}
if (editing?.id === user.id) cancelEdit();
loadUsers();
} catch (e) {
alert(e instanceof Error ? e.message : 'Failed to remove user');
}
}
onMount(loadUsers);
</script>
<svelte:head>
<title>Users — Admin — Trips</title>
</svelte:head>
<div class="mx-auto max-w-4xl">
<div class="mb-6 flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900">Users</h1>
<p class="mt-1 text-sm text-gray-500">Manage application user accounts and access.</p>
</div>
<button
onclick={openAddDrawer}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Add User
</button>
</div>
<div class="flex flex-col gap-4">
{#if error && !editing}
<div class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
{/if}
{#if editing}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-5">
<h3 class="mb-4 text-sm font-semibold text-gray-700">Edit user</h3>
{#if error}
<p class="mb-3 text-sm text-red-600">{error}</p>
{/if}
<div class="grid grid-cols-[1fr_1fr_1fr_auto] items-end gap-3">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500"
>Username <span class="text-red-500">*</span></label
>
<input
type="text"
bind:value={editUsername}
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500"
>Full name <span class="text-red-500">*</span></label
>
<input
type="text"
bind:value={editFullName}
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500">Email</label>
<input
type="email"
bind:value={editEmail}
placeholder="name@example.com"
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="flex gap-2">
<button
onclick={submitEdit}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Save
</button>
<button
onclick={cancelEdit}
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
</div>
</div>
</div>
{/if}
<div class="overflow-hidden rounded-lg border border-gray-200">
{#if loading}
<p class="p-8 text-center text-sm text-gray-500">Loading...</p>
{:else if users.length === 0}
<p class="p-8 text-center text-sm text-gray-500">No users found.</p>
{:else}
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
>Username</th
>
<th
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
>Full name</th
>
<th
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
>Email</th
>
<th
class="px-4 py-3 text-left text-xs font-semibold tracking-wide text-gray-500 uppercase"
>Auth source</th
>
<th
class="px-4 py-3 text-right text-xs font-semibold tracking-wide text-gray-500 uppercase"
>Actions</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each users as user (user.id)}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm font-medium text-gray-900">{user.username}</td>
<td class="px-4 py-3 text-sm text-gray-700">
{user.full_name || '—'}
</td>
<td class="px-4 py-3 text-sm text-gray-700">
{user.email || '—'}
</td>
<td class="px-4 py-3">
<div class="flex flex-wrap gap-1">
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700"
>{user.auth_source}</span
>
{#if user.has_local_credentials && user.auth_source !== 'Local'}
<span class="rounded bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700"
>Local</span
>
{/if}
</div>
</td>
<td class="px-4 py-3 text-right">
<div class="inline-flex items-center gap-1">
<button
onclick={() => openPasswordDrawer(user)}
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-blue-500 hover:bg-blue-50 hover:text-blue-600"
aria-label="Set local password"
title="Set local password"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="h-4 w-4"
>
<path d="M21 10h-6" />
<path d="M15 10V7a3 3 0 0 0-6 0v3" />
<rect x="3" y="10" width="12" height="10" rx="2" />
</svg>
</button>
<button
onclick={() => startEdit(user)}
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700"
aria-label="Edit user"
title="Edit"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="h-4 w-4"
>
<path d="M12 20h9" />
<path
d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"
/>
</svg>
</button>
<button
onclick={() => removeUser(user)}
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-red-500 hover:bg-red-50 hover:text-red-600"
aria-label="Remove user"
title="Remove"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="h-4 w-4"
>
<path d="M3 6h18" />
<path d="M8 6V4h8v2" />
<path d="M19 6l-1 14H6L5 6" />
</svg>
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
</div>
{#if addDrawerOpen}
<div
class="fixed inset-0 z-40 bg-black/20"
role="button"
tabindex="-1"
onclick={closeAddDrawer}
onkeydown={(e: KeyboardEvent) => e.key === 'Escape' && closeAddDrawer()}
></div>
<div
class="fixed top-0 right-0 z-50 flex h-full w-full max-w-md flex-col bg-white shadow-xl"
role="dialog"
aria-modal="true"
aria-label="Add user"
onkeydown={(e: KeyboardEvent) => e.key === 'Escape' && closeAddDrawer()}
>
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<div>
<h2 class="text-base font-semibold text-gray-900">Add user</h2>
<p class="text-xs text-gray-500">Create a new local login user.</p>
</div>
<button
onclick={closeAddDrawer}
class="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="Close"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<form class="flex flex-1 flex-col gap-4 overflow-y-auto px-6 py-4" onsubmit={(e) => { e.preventDefault(); submitAddUser(); }}>
{#if addError}
<p class="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{addError}
</p>
{/if}
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500">Username <span class="text-red-500">*</span></label>
<input
type="text"
bind:value={addUsername}
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500">Full name <span class="text-red-500">*</span></label>
<input
type="text"
bind:value={addFullName}
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500">Email</label>
<input
type="email"
placeholder="name@example.com"
bind:value={addEmail}
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500">Password <span class="text-red-500">*</span></label>
<input
type="password"
bind:value={addPassword}
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
<p class="text-xs text-gray-500">Minimum 12 characters.</p>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500">Confirm password <span class="text-red-500">*</span></label>
<input
type="password"
bind:value={addPasswordConfirm}
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="mt-2 flex items-center justify-end gap-2">
<button
type="button"
onclick={closeAddDrawer}
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
disabled={addSubmitting}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
>
{addSubmitting ? 'Creating...' : 'Create user'}
</button>
</div>
</form>
</div>
{/if}
{#if passwordDrawerUser}
<div
class="fixed inset-0 z-40 bg-black/20"
role="button"
tabindex="-1"
onclick={closePasswordDrawer}
onkeydown={(e: KeyboardEvent) => e.key === 'Escape' && closePasswordDrawer()}
></div>
<div
class="fixed top-0 right-0 z-50 flex h-full w-full max-w-md flex-col bg-white shadow-xl"
role="dialog"
aria-modal="true"
aria-label="Set local password"
onkeydown={(e: KeyboardEvent) => e.key === 'Escape' && closePasswordDrawer()}
>
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<div>
<h2 class="text-base font-semibold text-gray-900">Set local password</h2>
<p class="text-xs text-gray-500">{passwordDrawerUser.username}</p>
</div>
<button
onclick={closePasswordDrawer}
class="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="Close"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<form
class="flex flex-1 flex-col gap-4 overflow-y-auto px-6 py-4"
onsubmit={(e) => { e.preventDefault(); submitLocalPassword(); }}
>
{#if localPasswordError}
<p class="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{localPasswordError}
</p>
{/if}
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500">New password <span class="text-red-500">*</span></label>
<input
type="password"
bind:value={localPassword}
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
<p class="text-xs text-gray-500">Minimum 12 characters.</p>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-gray-500">Confirm password <span class="text-red-500">*</span></label>
<input
type="password"
bind:value={localPasswordConfirm}
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="mt-2 flex items-center justify-end gap-2">
<button
type="button"
onclick={closePasswordDrawer}
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
disabled={localPasswordSubmitting}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
>
{localPasswordSubmitting ? 'Saving...' : 'Save password'}
</button>
</div>
</form>
</div>
{/if}

View File

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

View File

@@ -1,10 +1,449 @@
<script lang="ts">
import { base } from '$app/paths';
import type { DashboardData, DashboardMapStop } from '$lib/server/dashboard.js';
let { data } = $props();
const dashboard = $derived(data.dashboard) as DashboardData;
const markers = $derived(
dashboard.mapStops.map((stop: DashboardMapStop, index: number) => {
const x = ((stop.lon + 180) / 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>
<svelte:head>
<title>Dashboard — Trips</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Fraunces:wght@600;700&family=Manrope:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</svelte:head>
<h1 class="mb-2 text-2xl font-bold text-gray-900">Dashboard</h1>
<p class="text-gray-600">Welcome, {data.session.user?.name ?? data.session.user?.email}.</p>
<div class="dashboard-shell space-y-8">
<header class="relative overflow-hidden rounded-3xl border border-slate-200 bg-gradient-to-br from-amber-50 via-white to-sky-50 p-6">
<div class="absolute -right-10 top-6 h-32 w-32 rounded-full bg-sky-200/40 blur-3xl"></div>
<div class="absolute -left-12 bottom-6 h-40 w-40 rounded-full bg-amber-200/40 blur-3xl"></div>
<div class="relative">
<p class="text-xs uppercase tracking-[0.2em] text-slate-500">Overview</p>
<h1 class="display mt-2 text-3xl text-slate-900 sm:text-4xl">Dashboard</h1>
<p class="mt-2 text-sm text-slate-600">
Welcome back, {data.session.user?.name ?? data.session.user?.email}. Heres a quick pulse on
your travel story.
</p>
</div>
</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-30"></div>
<div class="absolute -left-20 top-10 h-48 w-48 rounded-full bg-emerald-400/20 blur-3xl"></div>
<div class="absolute right-0 bottom-0 h-56 w-56 rounded-full bg-sky-400/20 blur-3xl"></div>
<svg
class="absolute inset-0 h-full w-full opacity-70"
viewBox="0 0 1000 500"
preserveAspectRatio="xMidYMid slice"
aria-hidden="true"
>
<rect width="1000" height="500" fill="none" />
<polygon class="map-land" points="110,140 220,90 330,120 360,200 290,260 170,240" />
<polygon class="map-land" points="260,260 330,260 370,320 340,430 260,400 230,320" />
<polygon class="map-land" points="420,120 520,110 560,150 520,190 450,170" />
<polygon class="map-land" points="450,200 560,210 610,320 550,420 470,340" />
<polygon class="map-land" points="580,120 760,110 880,200 780,270 620,220" />
<polygon class="map-land" points="740,320 830,330 860,400 800,440 720,380" />
</svg>
<div class="relative">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs uppercase tracking-[0.2em] text-emerald-200">World map</p>
<h2 class="display mt-2 text-xl text-white">Past &amp; future routes</h2>
<p class="mt-2 text-sm text-slate-200/80">
Pins highlight places from your journey log and whats still ahead.
</p>
</div>
<div class="flex flex-col gap-2 text-xs text-slate-200">
<div class="flex items-center gap-2">
<span class="legend-dot legend-past"></span>
Past trips
</div>
<div class="flex items-center gap-2">
<span class="legend-dot legend-current"></span>
In progress
</div>
<div class="flex items-center gap-2">
<span class="legend-dot legend-future"></span>
Upcoming
</div>
</div>
</div>
<div class="relative mt-6 h-64">
{#if markers.length === 0}
<div class="flex h-full items-center justify-center rounded-2xl border border-dashed border-white/30 text-sm text-slate-200">
Add destinations to your trips to light up the map.
</div>
{:else}
{#each markers as marker}
<span
class="map-marker map-marker--{marker.status}"
data-testid="map-stop"
data-status={marker.status}
style={`left:${marker.x}%; top:${marker.y}%;`}
title={`${marker.tripName} · ${marker.label}`}
></span>
{/each}
{/if}
</div>
</div>
</div>
<div class="grid gap-4">
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm">
<p class="text-xs uppercase tracking-[0.2em] text-slate-500">Travel diary</p>
<h2 class="display mt-2 text-xl text-slate-900">Where youve been</h2>
<div class="mt-4 grid grid-cols-2 gap-4">
<div class="stat-card">
<span class="stat-label">Past trips</span>
<span class="stat-value" data-testid="stat-past-trips">{dashboard.stats.pastTrips}</span>
</div>
<div class="stat-card">
<span class="stat-label">Days away</span>
<span class="stat-value" data-testid="stat-days-away">{dashboard.stats.daysAway}</span>
</div>
<div class="stat-card">
<span class="stat-label">Countries visited</span>
<span class="stat-value" data-testid="stat-countries">{dashboard.stats.countriesVisited}</span>
</div>
<div class="stat-card">
<span class="stat-label">Cities explored</span>
<span class="stat-value" data-testid="stat-cities">{dashboard.stats.citiesVisited}</span>
</div>
</div>
<div class="mt-5">
<p class="text-xs uppercase tracking-[0.2em] text-slate-500">Top destinations</p>
{#if dashboard.stats.topCountries.length === 0}
<p class="mt-2 text-sm text-slate-500">No past destinations yet.</p>
{:else}
<div class="mt-3 space-y-2">
{#each dashboard.stats.topCountries as country}
<div class="flex items-center justify-between text-sm text-slate-700">
<span>{country.country}</span>
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600">
{country.count} stop{country.count === 1 ? '' : 's'}
</span>
</div>
{/each}
</div>
{/if}
</div>
</div>
<div class="rounded-3xl border border-slate-200 bg-gradient-to-br from-slate-900 via-slate-900 to-emerald-900 p-5 text-white shadow-sm">
<p class="text-xs uppercase tracking-[0.2em] text-emerald-200">Next up</p>
<h2 class="display mt-2 text-xl">Trips in progress</h2>
{#if dashboard.inProgressTrips.length === 0}
<p class="mt-3 text-sm text-emerald-100/80">No trips are underway right now.</p>
{:else}
<div class="mt-4 space-y-3">
{#each dashboard.inProgressTrips as trip}
<a
href="{base}/trips/{trip.id}"
class="block rounded-2xl border border-white/10 bg-white/5 p-4 transition hover:bg-white/10"
>
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-white">{trip.name}</h3>
<span class="badge badge-current">In progress</span>
</div>
<p class="mt-1 text-xs text-emerald-100/80">
{formatRange(trip.start_date, trip.end_date)}
</p>
{#if progressPercent(trip.start_date, trip.end_date) !== null}
<div class="mt-3 h-1.5 w-full rounded-full bg-white/10">
<div
class="h-1.5 rounded-full bg-emerald-300"
style={`width:${progressPercent(trip.start_date, trip.end_date)}%`}
></div>
</div>
{/if}
<p class="mt-2 text-xs text-emerald-100/70">
{#if currentTripDay(trip.start_date, trip.end_date)}
Day {currentTripDay(trip.start_date, trip.end_date)?.current} of {currentTripDay(
trip.start_date,
trip.end_date
)?.total}
·
{/if}
{trip.planCount} plans logged
</p>
</a>
{/each}
</div>
{/if}
</div>
</div>
</section>
<section class="grid gap-6 lg:grid-cols-2">
<div class="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-xs uppercase tracking-[0.2em] text-slate-500">On the horizon</p>
<h2 class="display mt-2 text-xl text-slate-900">Trips being planned</h2>
</div>
<a href="{base}/trips/upcoming" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500 hover:text-slate-800">
View all
</a>
</div>
{#if dashboard.plannedTrips.length === 0}
<p class="mt-4 text-sm text-slate-500">No future trips yet. Start planning the next adventure.</p>
{:else}
<div class="mt-4 space-y-3">
{#each dashboard.plannedTrips as trip}
<a
href="{base}/trips/{trip.id}"
class="block rounded-2xl border border-slate-200 bg-slate-50 p-4 transition hover:bg-white"
>
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-slate-900">{trip.name}</h3>
<span class="badge badge-future">Planning</span>
</div>
<p class="mt-1 text-xs text-slate-500">{formatRange(trip.start_date, trip.end_date)}</p>
<p class="mt-2 text-xs text-slate-600">
{#if daysUntil(trip.start_date) !== null}
Starts in {daysUntil(trip.start_date)} day{daysUntil(trip.start_date) === 1 ? '' : 's'}
{:else}
Dates to be confirmed
{/if}
· {trip.planCount} plans sketched
</p>
</a>
{/each}
</div>
{/if}
</div>
<div class="rounded-3xl border border-slate-200 bg-gradient-to-br from-white via-white to-sky-50 p-6 shadow-sm">
<p class="text-xs uppercase tracking-[0.2em] text-slate-500">Quick actions</p>
<h2 class="display mt-2 text-xl text-slate-900">Build your next story</h2>
<p class="mt-2 text-sm text-slate-600">
Add a destination, log transportation, or save an idea to keep your timeline evolving.
</p>
<div class="mt-4 flex flex-wrap gap-3">
<a
href="{base}/trips/new"
class="rounded-full bg-slate-900 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-white transition hover:bg-slate-800"
>
Plan new trip
</a>
<a
href="{base}/trips/upcoming"
class="rounded-full border border-slate-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-slate-600 transition hover:border-slate-400 hover:text-slate-900"
>
Browse upcoming
</a>
<a
href="{base}/trips/past"
class="rounded-full border border-slate-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-slate-600 transition hover:border-slate-400 hover:text-slate-900"
>
Revisit past trips
</a>
</div>
</div>
</section>
</div>
<style>
.dashboard-shell {
font-family: 'Manrope', 'Helvetica Neue', Arial, sans-serif;
}
.display {
font-family: 'Fraunces', 'Times New Roman', serif;
}
.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-land {
fill: rgba(148, 163, 184, 0.25);
stroke: rgba(148, 163, 184, 0.35);
stroke-width: 2;
}
.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>

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

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

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -1,18 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 61">
<title>LATAM Airlines Chile</title>
<desc>LATAM Airlines Chile logo</desc>
<metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:Airline="http://schema.org/Airline">
<rdf:RDF>
<Airline:name>LATAM Airlines Chile</Airline:name>
<Airline:alternateName>LATAM Airlines</Airline:alternateName>
<Airline:iataCode>LA</Airline:iataCode>
<Airline:url>https://airlinelogos.aero/LA</Airline:url>
</rdf:RDF>
</metadata>
<path d="M129.669 47.129v13.726h2.162V47.129h-2.162zm37.725 0v13.726h11.085v-2.002h-8.924v-4.294h7.771v-1.857h-7.771v-3.715h8.632V47.13h-10.793zm-91.428 0v13.726h2.161V47.129h-2.161zm35.696 13.739h10.078v-1.857h-7.916V47.142h-2.162v13.726zm-16.548-6.007c-1.578 0-3.169-.435-4.177-1.001v-5.15h5.622c2.732 0 3.739 1.436 3.739 2.714 0 1.857-1.445 3.438-5.184 3.438zm-33.402.566h-6.338l3.169-6.442 3.169 6.442zm82.782 5.44V49.566l9.507 10.723c.292.435.716.566 1.3.566h1.299V47.129h-2.015v10.868l-9.362-10.301c-.292-.435-.57-.567-1.299-.567h-1.446v13.726h2.016v.013zm-53.557 0v-5.006c1.008.566 2.453.856 4.031.856.862 0 1.445 0 2.161-.145l2.878 4.294h2.307l-3.169-4.861c2.015-.856 3.315-2.569 3.315-4.571 0-2.569-2.162-4.439-6.047-4.439h-7.638v13.871h2.161zm-26.639 0h2.161l-6.484-13.16c-.292-.435-.57-.566-1.008-.566h-1.578l-6.763 13.726h2.148l1.724-3.57h8.062l1.737 3.57zm130.015-14.029c-3.74 0-6.193 1.581-6.193 4.005 0 2.292 1.87 3.148 4.893 3.715l1.445.29c2.162.435 3.315 1.146 3.315 2.292s-1.153 2.002-4.031 2.002c-2.585 0-4.614-.856-5.184-1.291l-.57 2.002c.57.29 2.585 1.146 5.609 1.146 4.031 0 6.338-1.568 6.338-4.005 0-2.292-2.016-3.438-5.185-4.004l-1.445-.29c-2.307-.435-3.023-.856-3.023-2.002s1.299-2.002 3.885-2.002c2.161 0 3.739.435 4.468.711l.438-1.712c-.875-.277-2.453-.856-4.76-.856zm-80.158-14.7h6.073V15.33l10.608-.698v-4.334h-27.289v4.334l10.608.698v16.808zm-43.837 0l2.493-5.032H58.079V10.298h-6.312v21.84h18.551zm23.47-8.444h-8.181l4.137-8.747 4.044 8.747zm54.909 0h-8.194l4.137-8.747 4.057 8.747zm27.753 3.109c.239.777 1.167 1.317 2.188 1.317h6.312l4.681-12.857 4.442 16.888H200l-4.906-19.364c-.464-1.936-1.565-2.476-3.355-2.476h-6.166l-4.442 12.778-3.514-10.854c-.464-1.383-1.326-1.936-2.731-1.936h-6.935l-5.689 21.84h6.007l4.363-16.887 3.818 11.553zm-23.629 5.335h6.63l-10.449-20.457c-.543-1.001-1.087-1.396-2.334-1.396H140.9l-11.005 21.854h6.709l1.949-4.031h12.319l1.949 4.031zm-54.976 0h6.71L94.106 11.694c-.544-1.001-1.087-1.396-2.347-1.396h-5.768l-10.992 21.84h6.696l1.949-4.031h12.319l1.883 4.031zM8.964 54.322l5.384-1.95c1.127-.408 1.127.487 1.127 1.831v2.463c0 1.989-.676 2.226-1.565 2.556L8.977 61v-6.679h-.013zm0-10.907l13.684-4.953c1.127-.408 1.127.487 1.127 1.831v2.45c0 2.002-.676 2.239-1.565 2.569L8.977 50.093v-6.679h-.013zM7.85 27.857L.902 25.341C0 25.025 0 23.813 0 23.366c0 0 0 1.462 1.339.975l34.078-12.316c2.692-.975 2.692-1.726 1.565-2.134 1.127.408 1.127.408 1.127 3.675 0 3.122 0 4.163-1.339 4.65l-26.679 9.642c-1.127.408-1.127.408-2.241 0zm29.146-7.06c1.127.408 1.127 1.159-1.565 2.134L8.964 32.494v6.679L36.77 29.122c1.339-.487 1.339-1.528 1.339-4.65 0-3.267 0-3.267-1.114-3.675z" fill="#1b0088"/>
<g fill="#ed1650">
<path d="M2.917 15.159l11.908 4.308 9.242-3.346L1.79 8.072C.225 7.505 0 7.426 0 8.546v2.898c0 2.661 2.016 3.399 2.917 3.715zm34.079 5.638l-3.7-1.33-9.242 3.346 5.848 2.121 5.516-1.989c2.692-.988 2.692-1.739 1.578-2.147z"/>
<path d="M36.996 9.89L10.767.405C9.189-.161 8.964-.24 8.964.866v2.898c0 2.674 2.016 3.399 2.917 3.728l18.034 6.521 5.516-1.989c2.678-.975 2.678-1.726 1.565-2.134zM1.339 24.34l7.624-2.753-7.174-2.595C.225 18.426 0 18.334 0 19.453v3.912s0 1.462 1.339.975z"/>
</g>
</svg>
<?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.6 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

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

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -1,18 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 61">
<title>LATAM Airlines Paraguay</title>
<desc>LATAM Airlines Paraguay logo</desc>
<metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:Airline="http://schema.org/Airline">
<rdf:RDF>
<Airline:name>LATAM Airlines Paraguay</Airline:name>
<Airline:alternateName>LATAM Airlines</Airline:alternateName>
<Airline:iataCode>PZ</Airline:iataCode>
<Airline:url>https://airlinelogos.aero/PZ</Airline:url>
</rdf:RDF>
</metadata>
<path d="M129.669 47.129v13.726h2.162V47.129h-2.162zm37.725 0v13.726h11.085v-2.002h-8.924v-4.294h7.771v-1.857h-7.771v-3.715h8.632V47.13h-10.793zm-91.428 0v13.726h2.161V47.129h-2.161zm35.696 13.739h10.078v-1.857h-7.916V47.142h-2.162v13.726zm-16.548-6.007c-1.578 0-3.169-.435-4.177-1.001v-5.15h5.622c2.732 0 3.739 1.436 3.739 2.714 0 1.857-1.445 3.438-5.184 3.438zm-33.402.566h-6.338l3.169-6.442 3.169 6.442zm82.782 5.44V49.566l9.507 10.723c.292.435.716.566 1.3.566h1.299V47.129h-2.015v10.868l-9.362-10.301c-.292-.435-.57-.567-1.299-.567h-1.446v13.726h2.016v.013zm-53.557 0v-5.006c1.008.566 2.453.856 4.031.856.862 0 1.445 0 2.161-.145l2.878 4.294h2.307l-3.169-4.861c2.015-.856 3.315-2.569 3.315-4.571 0-2.569-2.162-4.439-6.047-4.439h-7.638v13.871h2.161zm-26.639 0h2.161l-6.484-13.16c-.292-.435-.57-.566-1.008-.566h-1.578l-6.763 13.726h2.148l1.724-3.57h8.062l1.737 3.57zm130.015-14.029c-3.74 0-6.193 1.581-6.193 4.005 0 2.292 1.87 3.148 4.893 3.715l1.445.29c2.162.435 3.315 1.146 3.315 2.292s-1.153 2.002-4.031 2.002c-2.585 0-4.614-.856-5.184-1.291l-.57 2.002c.57.29 2.585 1.146 5.609 1.146 4.031 0 6.338-1.568 6.338-4.005 0-2.292-2.016-3.438-5.185-4.004l-1.445-.29c-2.307-.435-3.023-.856-3.023-2.002s1.299-2.002 3.885-2.002c2.161 0 3.739.435 4.468.711l.438-1.712c-.875-.277-2.453-.856-4.76-.856zm-80.158-14.7h6.073V15.33l10.608-.698v-4.334h-27.289v4.334l10.608.698v16.808zm-43.837 0l2.493-5.032H58.079V10.298h-6.312v21.84h18.551zm23.47-8.444h-8.181l4.137-8.747 4.044 8.747zm54.909 0h-8.194l4.137-8.747 4.057 8.747zm27.753 3.109c.239.777 1.167 1.317 2.188 1.317h6.312l4.681-12.857 4.442 16.888H200l-4.906-19.364c-.464-1.936-1.565-2.476-3.355-2.476h-6.166l-4.442 12.778-3.514-10.854c-.464-1.383-1.326-1.936-2.731-1.936h-6.935l-5.689 21.84h6.007l4.363-16.887 3.818 11.553zm-23.629 5.335h6.63l-10.449-20.457c-.543-1.001-1.087-1.396-2.334-1.396H140.9l-11.005 21.854h6.709l1.949-4.031h12.319l1.949 4.031zm-54.976 0h6.71L94.106 11.694c-.544-1.001-1.087-1.396-2.347-1.396h-5.768l-10.992 21.84h6.696l1.949-4.031h12.319l1.883 4.031zM8.964 54.322l5.384-1.95c1.127-.408 1.127.487 1.127 1.831v2.463c0 1.989-.676 2.226-1.565 2.556L8.977 61v-6.679h-.013zm0-10.907l13.684-4.953c1.127-.408 1.127.487 1.127 1.831v2.45c0 2.002-.676 2.239-1.565 2.569L8.977 50.093v-6.679h-.013zM7.85 27.857L.902 25.341C0 25.025 0 23.813 0 23.366c0 0 0 1.462 1.339.975l34.078-12.316c2.692-.975 2.692-1.726 1.565-2.134 1.127.408 1.127.408 1.127 3.675 0 3.122 0 4.163-1.339 4.65l-26.679 9.642c-1.127.408-1.127.408-2.241 0zm29.146-7.06c1.127.408 1.127 1.159-1.565 2.134L8.964 32.494v6.679L36.77 29.122c1.339-.487 1.339-1.528 1.339-4.65 0-3.267 0-3.267-1.114-3.675z" fill="#1b0088"/>
<g fill="#ed1650">
<path d="M2.917 15.159l11.908 4.308 9.242-3.346L1.79 8.072C.225 7.505 0 7.426 0 8.546v2.898c0 2.661 2.016 3.399 2.917 3.715zm34.079 5.638l-3.7-1.33-9.242 3.346 5.848 2.121 5.516-1.989c2.692-.988 2.692-1.739 1.578-2.147z"/>
<path d="M36.996 9.89L10.767.405C9.189-.161 8.964-.24 8.964.866v2.898c0 2.674 2.016 3.399 2.917 3.728l18.034 6.521 5.516-1.989c2.678-.975 2.678-1.726 1.565-2.134zM1.339 24.34l7.624-2.753-7.174-2.595C.225 18.426 0 18.334 0 19.453v3.912s0 1.462 1.339.975z"/>
</g>
<?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.6 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -1,18 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 61">
<title>LATAM Airlines Ecuador</title>
<desc>LATAM Airlines Ecuador logo</desc>
<metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:Airline="http://schema.org/Airline">
<rdf:RDF>
<Airline:name>LATAM Airlines Ecuador</Airline:name>
<Airline:alternateName>LATAM Airlines</Airline:alternateName>
<Airline:iataCode>XL</Airline:iataCode>
<Airline:url>https://airlinelogos.aero/XL</Airline:url>
</rdf:RDF>
</metadata>
<path d="M129.669 47.129v13.726h2.162V47.129h-2.162zm37.725 0v13.726h11.085v-2.002h-8.924v-4.294h7.771v-1.857h-7.771v-3.715h8.632V47.13h-10.793zm-91.428 0v13.726h2.161V47.129h-2.161zm35.696 13.739h10.078v-1.857h-7.916V47.142h-2.162v13.726zm-16.548-6.007c-1.578 0-3.169-.435-4.177-1.001v-5.15h5.622c2.732 0 3.739 1.436 3.739 2.714 0 1.857-1.445 3.438-5.184 3.438zm-33.402.566h-6.338l3.169-6.442 3.169 6.442zm82.782 5.44V49.566l9.507 10.723c.292.435.716.566 1.3.566h1.299V47.129h-2.015v10.868l-9.362-10.301c-.292-.435-.57-.567-1.299-.567h-1.446v13.726h2.016v.013zm-53.557 0v-5.006c1.008.566 2.453.856 4.031.856.862 0 1.445 0 2.161-.145l2.878 4.294h2.307l-3.169-4.861c2.015-.856 3.315-2.569 3.315-4.571 0-2.569-2.162-4.439-6.047-4.439h-7.638v13.871h2.161zm-26.639 0h2.161l-6.484-13.16c-.292-.435-.57-.566-1.008-.566h-1.578l-6.763 13.726h2.148l1.724-3.57h8.062l1.737 3.57zm130.015-14.029c-3.74 0-6.193 1.581-6.193 4.005 0 2.292 1.87 3.148 4.893 3.715l1.445.29c2.162.435 3.315 1.146 3.315 2.292s-1.153 2.002-4.031 2.002c-2.585 0-4.614-.856-5.184-1.291l-.57 2.002c.57.29 2.585 1.146 5.609 1.146 4.031 0 6.338-1.568 6.338-4.005 0-2.292-2.016-3.438-5.185-4.004l-1.445-.29c-2.307-.435-3.023-.856-3.023-2.002s1.299-2.002 3.885-2.002c2.161 0 3.739.435 4.468.711l.438-1.712c-.875-.277-2.453-.856-4.76-.856zm-80.158-14.7h6.073V15.33l10.608-.698v-4.334h-27.289v4.334l10.608.698v16.808zm-43.837 0l2.493-5.032H58.079V10.298h-6.312v21.84h18.551zm23.47-8.444h-8.181l4.137-8.747 4.044 8.747zm54.909 0h-8.194l4.137-8.747 4.057 8.747zm27.753 3.109c.239.777 1.167 1.317 2.188 1.317h6.312l4.681-12.857 4.442 16.888H200l-4.906-19.364c-.464-1.936-1.565-2.476-3.355-2.476h-6.166l-4.442 12.778-3.514-10.854c-.464-1.383-1.326-1.936-2.731-1.936h-6.935l-5.689 21.84h6.007l4.363-16.887 3.818 11.553zm-23.629 5.335h6.63l-10.449-20.457c-.543-1.001-1.087-1.396-2.334-1.396H140.9l-11.005 21.854h6.709l1.949-4.031h12.319l1.949 4.031zm-54.976 0h6.71L94.106 11.694c-.544-1.001-1.087-1.396-2.347-1.396h-5.768l-10.992 21.84h6.696l1.949-4.031h12.319l1.883 4.031zM8.964 54.322l5.384-1.95c1.127-.408 1.127.487 1.127 1.831v2.463c0 1.989-.676 2.226-1.565 2.556L8.977 61v-6.679h-.013zm0-10.907l13.684-4.953c1.127-.408 1.127.487 1.127 1.831v2.45c0 2.002-.676 2.239-1.565 2.569L8.977 50.093v-6.679h-.013zM7.85 27.857L.902 25.341C0 25.025 0 23.813 0 23.366c0 0 0 1.462 1.339.975l34.078-12.316c2.692-.975 2.692-1.726 1.565-2.134 1.127.408 1.127.408 1.127 3.675 0 3.122 0 4.163-1.339 4.65l-26.679 9.642c-1.127.408-1.127.408-2.241 0zm29.146-7.06c1.127.408 1.127 1.159-1.565 2.134L8.964 32.494v6.679L36.77 29.122c1.339-.487 1.339-1.528 1.339-4.65 0-3.267 0-3.267-1.114-3.675z" fill="#1b0088"/>
<g fill="#ed1650">
<path d="M2.917 15.159l11.908 4.308 9.242-3.346L1.79 8.072C.225 7.505 0 7.426 0 8.546v2.898c0 2.661 2.016 3.399 2.917 3.715zm34.079 5.638l-3.7-1.33-9.242 3.346 5.848 2.121 5.516-1.989c2.692-.988 2.692-1.739 1.578-2.147z"/>
<path d="M36.996 9.89L10.767.405C9.189-.161 8.964-.24 8.964.866v2.898c0 2.674 2.016 3.399 2.917 3.728l18.034 6.521 5.516-1.989c2.678-.975 2.678-1.726 1.565-2.134zM1.339 24.34l7.624-2.753-7.174-2.595C.225 18.426 0 18.334 0 19.453v3.912s0 1.462 1.339.975z"/>
</g>
</svg>
<?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.6 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

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