Merge pull request 'feat: web UI auth — login page, session store, Shell, TanStack Query' (#17) from dev into main
This commit was merged in pull request #17.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
||||
# Web UI Auth — Design Spec
|
||||
|
||||
**Date:** 2026-04-22
|
||||
**Status:** Design approved
|
||||
**Follows:** [Web UI Scaffold](2026-04-20-web-ui-scaffold-design.md) — this spec is the first SPA feature on top of the scaffold.
|
||||
|
||||
## Goal
|
||||
|
||||
Let a browser visitor sign in with a username and password, reach a protected app shell, and sign out again. After this lands, every subsequent sub-plan (library views, search, player) builds on top of a known-authenticated `user` store, a persistent Shell component, and a typed HTTP client.
|
||||
|
||||
## Non-goals
|
||||
|
||||
Explicit YAGNI — kept out of this plan so the scope stays tight:
|
||||
|
||||
- No self-service sign-up or password reset. Users are admin-provisioned; a forgotten password is a DBA problem for now.
|
||||
- No "remember me" / persistent-session checkbox. The server cookie already has a 30-day `MaxAge`; every login is long-lived.
|
||||
- No OAuth / OIDC / third-party sign-in. Deferred (see memory — OIDC is pushed to post-v1).
|
||||
- No multi-tab logout propagation via `BroadcastChannel`. If you log out in one tab, the other tab will discover it on the next API call (401 → silent logout). Good enough.
|
||||
- No rate-limiting UX on the login form. The backend already 401s on bad creds; brute-force mitigation is a server-side concern tracked elsewhere.
|
||||
|
||||
## Architecture
|
||||
|
||||
Three layers stacked under the SvelteKit app:
|
||||
|
||||
1. **Transport (`lib/api/`).** `apiFetch(path, init)` hits the relative `/api/*` origin, sets `Content-Type: application/json`, parses the JSON response, and throws a typed `ApiError{code, message, status}` on any non-2xx. A thin facade `api.get<T>`, `api.post<T>`, `api.del` wraps it. 401 responses trigger `auth.logout({silent: true})` before the error propagates.
|
||||
|
||||
2. **Auth state (`lib/auth/`).** A Svelte 5 rune-based store — `let _user = $state<User|null>(null)` plus helpers `bootstrap()`, `login(u, p)`, `logout()`. The store is the single synchronous source of truth for "is the current user authenticated, and if so who."
|
||||
|
||||
3. **Query layer (`lib/query/`).** TanStack Query's Svelte 5 adapter. A `QueryClient` is instantiated once in the root layout and installed via `<QueryClientProvider>`. Components consume data with `createQuery({queryKey, queryFn: () => api.get<T>(path)})`. `auth.logout()` calls `queryClient.clear()` so stale data doesn't leak to the next user.
|
||||
|
||||
**Bootstrap timing is load-time, not render-time.** The root `+layout.ts` `load()` awaits `auth.bootstrap()` before SvelteKit renders, so `+layout.svelte` sees the store already populated. There is no "flash of login screen" on refresh.
|
||||
|
||||
## File structure
|
||||
|
||||
New files under `web/`:
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib/
|
||||
│ ├── api/
|
||||
│ │ ├── client.ts # apiFetch + api.{get,post,del} + ApiError + User/LoginResponse types
|
||||
│ │ └── client.test.ts
|
||||
│ ├── auth/
|
||||
│ │ ├── store.svelte.ts # $user rune, bootstrap/login/logout
|
||||
│ │ └── store.test.ts
|
||||
│ ├── query/
|
||||
│ │ └── client.ts # export const queryClient = new QueryClient({...})
|
||||
│ └── components/
|
||||
│ ├── Shell.svelte # header + sidebar + <main> slot
|
||||
│ └── Shell.test.ts
|
||||
├── routes/
|
||||
│ ├── +layout.ts # load() → await auth.bootstrap()
|
||||
│ ├── +layout.svelte # QueryClientProvider + guard + Shell or bare <slot/>
|
||||
│ ├── +page.svelte # placeholder "Library" — replaced by the library plan
|
||||
│ ├── login/
|
||||
│ │ ├── +page.svelte # LoginForm
|
||||
│ │ └── +page.test.ts
|
||||
│ ├── search/+page.svelte # "Coming soon" placeholder
|
||||
│ └── playlists/+page.svelte # "Coming soon" placeholder
|
||||
└── app.d.ts # (untouched)
|
||||
```
|
||||
|
||||
Files that change:
|
||||
- `web/package.json` — add `@tanstack/svelte-query`, `@testing-library/svelte`, `@testing-library/jest-dom`.
|
||||
- `web/vitest.config.ts` — include `./vitest.setup.ts` to install jest-dom matchers.
|
||||
- `web/src/routes/+page.svelte` — replace the scaffold placeholder with a bare "Library" placeholder that survives into the library plan.
|
||||
|
||||
## Routes
|
||||
|
||||
| Path | Guard | Component |
|
||||
|---|---|---|
|
||||
| `/login` | public; if already authenticated, navigate to `/` | `routes/login/+page.svelte` |
|
||||
| `/` | guarded | Shell + `routes/+page.svelte` (Library placeholder) |
|
||||
| `/search` | guarded | Shell + placeholder |
|
||||
| `/playlists` | guarded | Shell + placeholder |
|
||||
| (anything else) | guarded | Shell + whatever the URL resolves to, or 404 |
|
||||
|
||||
The guard lives in `+layout.svelte` (not per-page `+page.ts`). On every render, it checks `user.value`. If `null` and path is not `/login`, it calls `goto('/login?returnTo=' + encodeURIComponent(path), {replaceState: true})`. If non-null and path is `/login`, it calls `goto('/', {replaceState: true})`. Otherwise it renders `<Shell><slot/></Shell>` (authed) or `<slot/>` (bare `/login`).
|
||||
|
||||
## `apiFetch` contract
|
||||
|
||||
```ts
|
||||
// lib/api/client.ts
|
||||
export type ApiError = { code: string; message: string; status: number };
|
||||
export type User = { id: string; username: string; is_admin: boolean };
|
||||
export type LoginResponse = { token: string; user: User };
|
||||
|
||||
export async function apiFetch(path: string, init?: RequestInit): Promise<unknown> {
|
||||
const res = await fetch(path, {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
|
||||
...init,
|
||||
});
|
||||
const body = res.status === 204 ? null : await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
// Avoid a circular import: lazy-require the auth store.
|
||||
const { logout } = await import('$lib/auth/store.svelte');
|
||||
logout({ silent: true });
|
||||
}
|
||||
const err = (body && body.error) || { code: 'unknown', message: res.statusText };
|
||||
throw { ...err, status: res.status } as ApiError;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(p: string) => apiFetch(p) as Promise<T>,
|
||||
post: <T>(p: string, b: unknown) =>
|
||||
apiFetch(p, { method: 'POST', body: JSON.stringify(b) }) as Promise<T>,
|
||||
del: (p: string) => apiFetch(p, { method: 'DELETE' }) as Promise<void>,
|
||||
};
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `credentials: 'same-origin'` is correct for both the Vite proxy (dev) and the embedded production serve — both are same-origin. Cross-origin would need `'include'` plus a CORS policy, which the backend does not currently implement.
|
||||
- `body.error` matches the backend's error envelope: `{"error":{"code":"...","message":"..."}}` (see `internal/api/errors.go`). Non-JSON error bodies degrade to `{code:'unknown', message: statusText}` — pragmatic.
|
||||
- Lazy-importing `$lib/auth/store.svelte` inside the 401 branch avoids a circular import (`auth/store` imports `api`).
|
||||
|
||||
## `auth` store contract
|
||||
|
||||
```ts
|
||||
// lib/auth/store.svelte.ts
|
||||
import { api, type User, type LoginResponse } from '$lib/api/client';
|
||||
import { queryClient } from '$lib/query/client';
|
||||
|
||||
let _user = $state<User | null>(null);
|
||||
export const user = { get value() { return _user; } };
|
||||
|
||||
export async function bootstrap(): Promise<void> {
|
||||
try { _user = await api.get<User>('/api/me'); }
|
||||
catch { _user = null; }
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string): Promise<void> {
|
||||
const res = await api.post<LoginResponse>('/api/auth/login', { username, password });
|
||||
_user = res.user;
|
||||
}
|
||||
|
||||
export async function logout(opts: { silent?: boolean } = {}): Promise<void> {
|
||||
if (!opts.silent) {
|
||||
try { await api.post('/api/auth/logout', {}); } catch { /* best effort */ }
|
||||
}
|
||||
_user = null;
|
||||
queryClient.clear();
|
||||
}
|
||||
```
|
||||
|
||||
The `silent: true` path is used only by `apiFetch`'s 401 interceptor. Without it, a 401 response would trigger a logout POST that itself 401s — pointless round-trip. Everywhere else (the Shell's "Log out" button), `logout()` is called without flags and does the full round-trip.
|
||||
|
||||
## Login page
|
||||
|
||||
**Layout.** Centered card on the dark palette. Minstrel wordmark above the card. The card contains the form: label+input for username, label+input for password, a full-width "Sign in" button, and a slot for error text below the button.
|
||||
|
||||
**Behavior.**
|
||||
|
||||
1. Username and password are bound to local `$state` with `$state('')`.
|
||||
2. On submit (button click or Enter in either field):
|
||||
- Set `pending = true`. Disable the button and set `aria-busy="true"`.
|
||||
- `await login(username, password)`.
|
||||
- On success: read `returnTo` from `$page.url.searchParams`; validate it matches `/^\/(?!\/)/` (starts with exactly one `/`, not `//`, which would be a protocol-relative URL) AND does not start with `/login`; `goto(validatedReturnTo || '/', {replaceState: true})`.
|
||||
- On `ApiError{code: 'invalid_credentials', status: 401}`: set `error = 'Invalid username or password.'`, clear password.
|
||||
- On any other error: set `error = 'Sign-in unavailable. Try again in a moment.'`, leave fields intact.
|
||||
- Finally: `pending = false`.
|
||||
3. Enter in either input triggers form `submit`.
|
||||
4. The error region has `role="alert"` so screen readers announce it on change.
|
||||
|
||||
## Shell component
|
||||
|
||||
**Structure.**
|
||||
|
||||
```
|
||||
<div class="h-screen grid grid-cols-[auto_1fr] grid-rows-[auto_1fr]">
|
||||
<header class="col-span-2 ...">
|
||||
Minstrel [username ▾] (dropdown has "Log out")
|
||||
</header>
|
||||
<nav class="hidden md:block ...">
|
||||
<a href="/">Library</a>
|
||||
<a href="/search">Search</a>
|
||||
<a href="/playlists">Playlists</a>
|
||||
</nav>
|
||||
<main class="overflow-y-auto">{@render children()}</main>
|
||||
</div>
|
||||
```
|
||||
|
||||
- Sidebar hides below `md:`. (Mobile gets a hamburger button in a later plan — not this one.)
|
||||
- Active nav item uses `aria-current="page"` so the active-styling CSS selector is semantic.
|
||||
- The user-menu dropdown is a minimal disclosure: click to toggle, outside-click closes, Escape closes. Inside it is a single `<button>Log out</button>` that calls `auth.logout()` then `goto('/login', {replaceState: true})`.
|
||||
|
||||
## Error handling summary
|
||||
|
||||
| Source | Shape | User sees |
|
||||
|---|---|---|
|
||||
| Login, bad creds | `ApiError{code:'invalid_credentials', status:401}` | Inline text under form: "Invalid username or password." |
|
||||
| Login, server 500 / network | `ApiError{status:500}` or fetch rejection | Inline text: "Sign-in unavailable. Try again in a moment." |
|
||||
| 401 mid-session on any API call | `ApiError{code:'unauthorized', status:401}` | Store clears; guard navigates to `/login?returnTo=<current>`; no visible error |
|
||||
| Non-2xx elsewhere | `ApiError` thrown into TanStack Query | TanStack Query `error` state; each consuming component renders its own UI (out of scope for this plan — the library plan handles it) |
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit (Vitest, jsdom).**
|
||||
|
||||
- `api/client.test.ts` — `fetch` stubbed via `vi.stubGlobal`.
|
||||
- `api.get` resolves with parsed JSON on 200.
|
||||
- `api.post` serializes body and sets `Content-Type`.
|
||||
- Non-2xx throws `ApiError` with `code`, `message`, `status`.
|
||||
- 401 calls `auth.logout({silent: true})` — spy on the dynamically-imported `logout` via module mocking.
|
||||
- 204 resolves to `null` without calling `.json()`.
|
||||
- JSON parse failure on an error body degrades to `{code:'unknown', message: statusText}`.
|
||||
|
||||
- `auth/store.test.ts` — `api` module mocked.
|
||||
- `bootstrap()` sets `user` on success; leaves `null` on rejection.
|
||||
- `login()` stores the returned `user`.
|
||||
- `logout()` calls `api.post('/api/auth/logout', {})`, clears `user`, calls `queryClient.clear()`.
|
||||
- `logout({silent: true})` does NOT call `api.post`; still clears store and cache.
|
||||
|
||||
- `components/Shell.test.ts` — render with a non-null `user`.
|
||||
- Header shows `user.username`.
|
||||
- Sidebar has exactly three `<a>` elements to `/`, `/search`, `/playlists`.
|
||||
- When `$page.url.pathname === '/'`, the Library link has `aria-current="page"`.
|
||||
- Clicking the user menu reveals "Log out"; outside-click hides it.
|
||||
|
||||
**Component test — `routes/login/+page.test.ts`** (using `@testing-library/svelte`).
|
||||
- Renders two inputs + submit button.
|
||||
- Invalid creds: stub `api.post` to reject with `{code:'invalid_credentials', status:401}`; submit; assert inline "Invalid username or password." is shown; password input is empty; username input is preserved.
|
||||
- Server error: stub to reject with `{status:500}`; assert generic error message; both inputs preserved.
|
||||
- Success: stub to resolve; set `?returnTo=/artists/abc` in `$page.url`; assert `goto` called with `/artists/abc` and `{replaceState: true}`.
|
||||
- Invalid `returnTo` variants: `http://evil.com`, `//evil.com`, `/login` → each should fall back to `goto('/')`, never the original value.
|
||||
- Loading: during a pending promise, `aria-busy="true"` on the button and button is disabled.
|
||||
|
||||
**Manual integration (Plan Task 8).**
|
||||
- `npm run dev` with backend running on `:4533`. Seed a user via the existing auth plan's flow.
|
||||
- Visit `/artists/abc` → redirected to `/login?returnTo=%2Fartists%2Fabc`.
|
||||
- Sign in → land on `/artists/abc` (which 404s from SPA fallback — that's expected; library plan fixes it).
|
||||
- Hard-refresh `/` while authenticated → Shell renders immediately, no login flash.
|
||||
- Click user menu → "Log out" → redirected to `/login`. Visit `/` → redirected back to `/login`.
|
||||
- In a second tab, also authenticated: open dev tools, delete the `minstrel_session` cookie manually, navigate in the SPA → auto-redirected to `/login`.
|
||||
|
||||
## Dependencies added
|
||||
|
||||
| Package | Kind | Why |
|
||||
|---|---|---|
|
||||
| `@tanstack/svelte-query` | runtime | Data-layer caching/invalidation per design section 1. |
|
||||
| `@testing-library/svelte` | dev | Component test rendering. |
|
||||
| `@testing-library/jest-dom` | dev | `toBeInTheDocument`, `toHaveAttribute`, etc. |
|
||||
|
||||
Versions pinned to latest Svelte-5-compatible majors at plan-writing time.
|
||||
|
||||
## Success criteria
|
||||
|
||||
- A user can sign in with valid creds, see the Shell, navigate to `/`, `/search`, `/playlists`, and log out.
|
||||
- Deep link `/artists/<any>` while signed out lands on `/login?returnTo=<path>`; after login, lands on `<path>`.
|
||||
- Refreshing any guarded page while authenticated renders immediately with no login flash.
|
||||
- A 401 on any API call silently clears auth and redirects to `/login`.
|
||||
- All unit and component tests pass in CI (`npm test` in the `web/` directory).
|
||||
- No backend changes required; the plan is pure frontend.
|
||||
Generated
+310
-20
@@ -7,10 +7,15 @@
|
||||
"": {
|
||||
"name": "minstrel-web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@tanstack/svelte-query": "^5.90.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.8.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jsdom": "^25.0.1",
|
||||
"postcss": "^8.4.49",
|
||||
@@ -23,6 +28,13 @@
|
||||
"vitest": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
"version": "4.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
|
||||
"integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||
@@ -50,6 +62,41 @@
|
||||
"lru-cache": "^10.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||
@@ -560,7 +607,6 @@
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@@ -571,7 +617,6 @@
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
@@ -582,7 +627,6 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -592,14 +636,12 @@
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
@@ -1051,7 +1093,6 @@
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
|
||||
"integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"acorn": "^8.9.0"
|
||||
@@ -1149,6 +1190,136 @@
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.90.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.2.tgz",
|
||||
"integrity": "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/svelte-query": {
|
||||
"version": "5.90.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/svelte-query/-/svelte-query-5.90.2.tgz",
|
||||
"integrity": "sha512-owjnp0w8sOXlMhLZhucHrsYvCjgjHrVyII/wlqMGefxKFyroZS3xCwTee+IUx7UHbL+QmKr/HQTeTqhgxmxPQw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^3.54.0 || ^4.0.0 || ^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"picocolors": "1.1.1",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom/node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom/node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "6.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
||||
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@adobe/css-tools": "^4.4.0",
|
||||
"aria-query": "^5.0.0",
|
||||
"css.escape": "^1.5.1",
|
||||
"dom-accessibility-api": "^0.6.3",
|
||||
"picocolors": "^1.1.1",
|
||||
"redent": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/svelte": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.3.1.tgz",
|
||||
"integrity": "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@testing-library/dom": "9.x.x || 10.x.x",
|
||||
"@testing-library/svelte-core": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0",
|
||||
"vite": "*",
|
||||
"vitest": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"vite": {
|
||||
"optional": true
|
||||
},
|
||||
"vitest": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/svelte-core": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz",
|
||||
"integrity": "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
@@ -1160,14 +1331,12 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
@@ -1287,7 +1456,6 @@
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
@@ -1306,6 +1474,29 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/any-promise": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
@@ -1351,7 +1542,6 @@
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
||||
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -1415,7 +1605,6 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -1596,7 +1785,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -1635,6 +1823,13 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/css.escape": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -1738,11 +1933,20 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/devalue": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz",
|
||||
"integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/didyoumean": {
|
||||
@@ -1759,6 +1963,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
|
||||
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -1903,14 +2114,12 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
||||
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esrap": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz",
|
||||
"integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15"
|
||||
@@ -2232,6 +2441,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/indent-string": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
||||
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -2305,7 +2524,6 @@
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.6"
|
||||
@@ -2321,6 +2539,13 @@
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "25.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz",
|
||||
@@ -2396,7 +2621,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
@@ -2413,11 +2637,20 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
@@ -2493,6 +2726,16 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/min-indent": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mri": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
@@ -2835,6 +3078,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^17.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -2866,6 +3124,13 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -2890,6 +3155,20 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"indent-string": "^4.0.0",
|
||||
"strip-indent": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -3085,6 +3364,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/strip-indent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"min-indent": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||
@@ -3125,7 +3417,6 @@
|
||||
"version": "5.55.4",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz",
|
||||
"integrity": "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
@@ -3791,7 +4082,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.8.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jsdom": "^25.0.1",
|
||||
"postcss": "^8.4.49",
|
||||
@@ -24,5 +26,8 @@
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vitest": "^2.1.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/svelte-query": "^5.90.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { api, apiFetch } from './client';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function stubFetch(status: number, body: unknown, init: Partial<Response> = {}) {
|
||||
const res = new Response(
|
||||
body === null ? null : JSON.stringify(body),
|
||||
{ status, headers: { 'Content-Type': 'application/json' }, ...init }
|
||||
);
|
||||
const spy = vi.fn().mockResolvedValue(res);
|
||||
vi.stubGlobal('fetch', spy);
|
||||
return spy;
|
||||
}
|
||||
|
||||
describe('apiFetch', () => {
|
||||
test('resolves with parsed JSON on 200', async () => {
|
||||
stubFetch(200, { hello: 'world' });
|
||||
const result = await apiFetch('/api/ping');
|
||||
expect(result).toEqual({ hello: 'world' });
|
||||
});
|
||||
|
||||
test('sends Content-Type application/json by default', async () => {
|
||||
const spy = stubFetch(200, {});
|
||||
await apiFetch('/api/ping');
|
||||
const init = spy.mock.calls[0][1] as RequestInit;
|
||||
expect((init.headers as Record<string, string>)['Content-Type']).toBe('application/json');
|
||||
});
|
||||
|
||||
test('resolves to null on 204', async () => {
|
||||
stubFetch(204, null);
|
||||
const result = await apiFetch('/api/ping', { method: 'DELETE' });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('throws ApiError from error envelope on 4xx', async () => {
|
||||
stubFetch(404, { error: { code: 'not_found', message: 'track not found' } });
|
||||
await expect(apiFetch('/api/tracks/x')).rejects.toMatchObject({
|
||||
code: 'not_found',
|
||||
message: 'track not found',
|
||||
status: 404
|
||||
});
|
||||
});
|
||||
|
||||
test('degrades to {code:unknown, message:statusText} on non-JSON error body', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
new Response('<html>500</html>', { status: 500, statusText: 'Internal Server Error' })
|
||||
));
|
||||
await expect(apiFetch('/api/ping')).rejects.toMatchObject({
|
||||
code: 'unknown',
|
||||
message: 'Internal Server Error',
|
||||
status: 500
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('api.get/post/del', () => {
|
||||
test('api.get returns typed JSON on 200', async () => {
|
||||
stubFetch(200, { id: 'abc', name: 'A' });
|
||||
type T = { id: string; name: string };
|
||||
const result = await api.get<T>('/api/artists/abc');
|
||||
expect(result).toEqual({ id: 'abc', name: 'A' });
|
||||
});
|
||||
|
||||
test('api.post serializes body and sets method', async () => {
|
||||
const spy = stubFetch(200, { ok: true });
|
||||
await api.post('/api/auth/login', { username: 'u', password: 'p' });
|
||||
const init = spy.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.body).toBe(JSON.stringify({ username: 'u', password: 'p' }));
|
||||
});
|
||||
|
||||
test('api.del sends DELETE and resolves to null on 204', async () => {
|
||||
const spy = stubFetch(204, null);
|
||||
const result = await api.del('/api/sessions/xyz');
|
||||
expect(spy.mock.calls[0][1]).toMatchObject({ method: 'DELETE' });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiFetch 401 interceptor', () => {
|
||||
test('401 response triggers auth.logout({silent:true}) and still throws', async () => {
|
||||
const logoutSpy = vi.fn();
|
||||
vi.doMock('$lib/auth/store.svelte', () => ({
|
||||
logout: logoutSpy,
|
||||
login: vi.fn(),
|
||||
bootstrap: vi.fn(),
|
||||
user: { value: null }
|
||||
}));
|
||||
// Re-import to pick up the mock.
|
||||
const { apiFetch: apiFetchFresh } = await import('./client');
|
||||
stubFetch(401, { error: { code: 'unauthorized', message: 'session expired' } });
|
||||
await expect(apiFetchFresh('/api/me')).rejects.toMatchObject({
|
||||
code: 'unauthorized',
|
||||
status: 401
|
||||
});
|
||||
expect(logoutSpy).toHaveBeenCalledWith({ silent: true });
|
||||
vi.doUnmock('$lib/auth/store.svelte');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
export type ApiError = {
|
||||
code: string;
|
||||
message: string;
|
||||
status: number;
|
||||
};
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
};
|
||||
|
||||
export type LoginResponse = {
|
||||
token: string;
|
||||
user: User;
|
||||
};
|
||||
|
||||
export async function apiFetch(path: string, init?: RequestInit): Promise<unknown> {
|
||||
const res = await fetch(path, {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
|
||||
...init
|
||||
});
|
||||
const body = res.status === 204 ? null : await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
// Lazy import: auth/store imports from this file, so a top-level
|
||||
// import would be circular. By the time any 401 actually happens
|
||||
// at runtime, both modules have finished loading.
|
||||
const { logout } = await import('$lib/auth/store.svelte');
|
||||
await logout({ silent: true });
|
||||
}
|
||||
const envelope = body && (body as { error?: { code?: string; message?: string } }).error;
|
||||
const err: ApiError = {
|
||||
code: envelope?.code ?? 'unknown',
|
||||
message: envelope?.message ?? res.statusText,
|
||||
status: res.status
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string): Promise<T> => apiFetch(path) as Promise<T>,
|
||||
post: <T>(path: string, body: unknown): Promise<T> =>
|
||||
apiFetch(path, { method: 'POST', body: JSON.stringify(body) }) as Promise<T>,
|
||||
del: (path: string): Promise<null> =>
|
||||
apiFetch(path, { method: 'DELETE' }) as Promise<null>
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { api, type User, type LoginResponse } from '$lib/api/client';
|
||||
import { queryClient } from '$lib/query/client';
|
||||
|
||||
let _user = $state<User | null>(null);
|
||||
|
||||
export const user = {
|
||||
get value(): User | null {
|
||||
return _user;
|
||||
}
|
||||
};
|
||||
|
||||
export async function bootstrap(): Promise<void> {
|
||||
try {
|
||||
_user = await api.get<User>('/api/me');
|
||||
} catch {
|
||||
_user = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string): Promise<void> {
|
||||
const res = await api.post<LoginResponse>('/api/auth/login', { username, password });
|
||||
_user = res.user;
|
||||
}
|
||||
|
||||
export async function logout(opts: { silent?: boolean } = {}): Promise<void> {
|
||||
if (!opts.silent) {
|
||||
try {
|
||||
await api.post('/api/auth/logout', {});
|
||||
} catch {
|
||||
// best effort — server-side session may already be gone
|
||||
}
|
||||
}
|
||||
_user = null;
|
||||
queryClient.clear();
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
vi.mock('$lib/api/client', () => ({
|
||||
api: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
del: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('$lib/query/client', () => ({
|
||||
queryClient: { clear: vi.fn() }
|
||||
}));
|
||||
|
||||
import { api } from '$lib/api/client';
|
||||
import { queryClient } from '$lib/query/client';
|
||||
import { bootstrap, login, logout, user } from './store.svelte';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Force-clear the store between tests by stubbing api.get to reject,
|
||||
// then calling bootstrap.
|
||||
});
|
||||
|
||||
describe('auth store', () => {
|
||||
test('bootstrap populates user on 200', async () => {
|
||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: '1', username: 'alice', is_admin: false
|
||||
});
|
||||
await bootstrap();
|
||||
expect(user.value).toEqual({ id: '1', username: 'alice', is_admin: false });
|
||||
});
|
||||
|
||||
test('bootstrap leaves user null on failure', async () => {
|
||||
(api.get as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
{ code: 'unauthorized', message: 'no session', status: 401 }
|
||||
);
|
||||
await bootstrap();
|
||||
expect(user.value).toBeNull();
|
||||
});
|
||||
|
||||
test('login sets user from LoginResponse.user', async () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
token: 't', user: { id: '2', username: 'bob', is_admin: true }
|
||||
});
|
||||
await login('bob', 'pw');
|
||||
expect(user.value).toEqual({ id: '2', username: 'bob', is_admin: true });
|
||||
expect(api.post).toHaveBeenCalledWith('/api/auth/login', {
|
||||
username: 'bob', password: 'pw'
|
||||
});
|
||||
});
|
||||
|
||||
test('logout clears user, calls /api/auth/logout, and clears query cache', async () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
await logout();
|
||||
expect(user.value).toBeNull();
|
||||
expect(api.post).toHaveBeenCalledWith('/api/auth/logout', {});
|
||||
expect(queryClient.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('logout with silent:true skips the POST but still clears state', async () => {
|
||||
await logout({ silent: true });
|
||||
expect(user.value).toBeNull();
|
||||
expect(api.post).not.toHaveBeenCalled();
|
||||
expect(queryClient.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('logout swallows POST errors (best-effort)', async () => {
|
||||
(api.post as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
{ code: 'server_error', message: 'boom', status: 500 }
|
||||
);
|
||||
await expect(logout()).resolves.toBeUndefined();
|
||||
expect(user.value).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { user, logout } from '$lib/auth/store.svelte';
|
||||
|
||||
let { children } = $props<{ children: import('svelte').Snippet }>();
|
||||
|
||||
let menuOpen = $state(false);
|
||||
|
||||
async function handleLogout() {
|
||||
menuOpen = false;
|
||||
await logout();
|
||||
goto('/login', { replaceState: true });
|
||||
}
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
if (href === '/') return page.url.pathname === '/';
|
||||
return page.url.pathname.startsWith(href);
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', label: 'Library' },
|
||||
{ href: '/search', label: 'Search' },
|
||||
{ href: '/playlists', label: 'Playlists' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={() => (menuOpen = false)} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} />
|
||||
|
||||
<div class="grid h-screen grid-cols-[auto_1fr] grid-rows-[auto_1fr] bg-background text-text-primary">
|
||||
<header class="col-span-2 flex items-center justify-between border-b border-border bg-surface px-4 py-3">
|
||||
<div class="font-semibold">Minstrel</div>
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-3 py-1 hover:bg-surface-hover"
|
||||
onclick={(e) => { e.stopPropagation(); menuOpen = !menuOpen; }}
|
||||
>
|
||||
{user.value?.username ?? ''}
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<div
|
||||
class="absolute right-0 mt-1 w-32 rounded border border-border bg-surface shadow"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full px-3 py-2 text-left hover:bg-surface-hover"
|
||||
onclick={handleLogout}
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="hidden w-48 border-r border-border bg-surface md:block">
|
||||
<ul class="p-2">
|
||||
{#each navItems as item}
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
class="block rounded px-3 py-2 hover:bg-surface-hover"
|
||||
aria-current={isActive(item.href) ? 'page' : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="overflow-y-auto p-4 md:col-start-2">
|
||||
{@render children?.()}
|
||||
</main>
|
||||
</div>
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
|
||||
vi.mock('$app/state', () => ({
|
||||
page: { url: new URL('http://localhost/') }
|
||||
}));
|
||||
|
||||
vi.mock('$app/navigation', () => ({
|
||||
goto: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/auth/store.svelte', () => ({
|
||||
user: { value: { id: '1', username: 'alice', is_admin: false } },
|
||||
logout: vi.fn().mockResolvedValue(undefined)
|
||||
}));
|
||||
|
||||
import Shell from './Shell.svelte';
|
||||
import { logout } from '$lib/auth/store.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Shell', () => {
|
||||
test('renders the username in the header', () => {
|
||||
render(Shell);
|
||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders three nav items: Library, Search, Playlists', () => {
|
||||
render(Shell);
|
||||
expect(screen.getByRole('link', { name: 'Library' })).toHaveAttribute('href', '/');
|
||||
expect(screen.getByRole('link', { name: 'Search' })).toHaveAttribute('href', '/search');
|
||||
expect(screen.getByRole('link', { name: 'Playlists' })).toHaveAttribute('href', '/playlists');
|
||||
});
|
||||
|
||||
test('user-menu "Log out" calls logout() and navigates to /login', async () => {
|
||||
render(Shell);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /alice/i }));
|
||||
await fireEvent.click(screen.getByRole('button', { name: /log out/i }));
|
||||
expect(logout).toHaveBeenCalledTimes(1);
|
||||
expect(goto).toHaveBeenCalledWith('/login', { replaceState: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { QueryClient } from '@tanstack/svelte-query';
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,31 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { QueryClientProvider } from '@tanstack/svelte-query';
|
||||
import { queryClient } from '$lib/query/client';
|
||||
import { user } from '$lib/auth/store.svelte';
|
||||
import Shell from '$lib/components/Shell.svelte';
|
||||
|
||||
let { children } = $props<{ children: import('svelte').Snippet }>();
|
||||
|
||||
// Reactive guard: runs every time page.url or user.value changes.
|
||||
$effect(() => {
|
||||
const path = page.url.pathname;
|
||||
const isLogin = path === '/login';
|
||||
if (user.value === null && !isLogin) {
|
||||
const returnTo = path + page.url.search;
|
||||
goto('/login?returnTo=' + encodeURIComponent(returnTo), { replaceState: true });
|
||||
} else if (user.value !== null && isLogin) {
|
||||
goto('/', { replaceState: true });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{#if user.value !== null && page.url.pathname !== '/login'}
|
||||
<Shell>{@render children()}</Shell>
|
||||
{:else}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { LayoutLoad } from './$types';
|
||||
import { bootstrap } from '$lib/auth/store.svelte';
|
||||
|
||||
export const ssr = false; // adapter-static fallback; we're SPA-only
|
||||
export const prerender = false;
|
||||
|
||||
export const load: LayoutLoad = async () => {
|
||||
await bootstrap();
|
||||
return {};
|
||||
};
|
||||
@@ -1,8 +1,2 @@
|
||||
<main class="flex h-screen items-center justify-center">
|
||||
<div class="text-center">
|
||||
<h1 class="text-4xl font-semibold">Minstrel</h1>
|
||||
<p class="mt-2 text-text-secondary">
|
||||
Scaffold — UI features land in subsequent plans.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
<h1 class="text-2xl font-semibold">Library</h1>
|
||||
<p class="mt-2 text-text-secondary">Library views land in a subsequent plan.</p>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { login } from '$lib/auth/store.svelte';
|
||||
import type { ApiError } from '$lib/api/client';
|
||||
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let error = $state<string | null>(null);
|
||||
let pending = $state(false);
|
||||
|
||||
function safeReturnTo(raw: string | null): string {
|
||||
if (!raw) return '/';
|
||||
// Starts with exactly one '/' (not '//', which is a protocol-relative URL)
|
||||
// AND is not the login page itself.
|
||||
if (!/^\/(?!\/)/.test(raw)) return '/';
|
||||
if (raw === '/login' || raw.startsWith('/login?') || raw.startsWith('/login/')) return '/';
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (pending) return;
|
||||
error = null;
|
||||
pending = true;
|
||||
try {
|
||||
await login(username, password);
|
||||
const dest = safeReturnTo(page.url.searchParams.get('returnTo'));
|
||||
goto(dest, { replaceState: true });
|
||||
} catch (err) {
|
||||
const apiErr = err as ApiError;
|
||||
if (apiErr?.status === 401 && apiErr?.code === 'invalid_credentials') {
|
||||
error = 'Invalid username or password.';
|
||||
password = '';
|
||||
} else {
|
||||
error = 'Sign-in unavailable. Try again in a moment.';
|
||||
}
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="flex min-h-screen items-center justify-center bg-background text-text-primary">
|
||||
<div class="w-full max-w-sm rounded-lg border border-border bg-surface p-6 shadow">
|
||||
<h1 class="mb-6 text-center text-2xl font-semibold">Minstrel</h1>
|
||||
<form class="space-y-4" onsubmit={handleSubmit}>
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-sm text-text-secondary">Username</span>
|
||||
<input
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
class="w-full rounded border border-border bg-background px-3 py-2 outline-none focus:border-accent"
|
||||
bind:value={username}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-sm text-text-secondary">Password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="w-full rounded border border-border bg-background px-3 py-2 outline-none focus:border-accent"
|
||||
bind:value={password}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded bg-accent px-3 py-2 font-medium text-background disabled:opacity-60"
|
||||
aria-busy={pending ? 'true' : undefined}
|
||||
disabled={pending}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
{#if error}
|
||||
<p role="alert" class="text-sm text-danger">{error}</p>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
@@ -0,0 +1,122 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
// vi.hoisted avoids the temporal-dead-zone hazard: mock factories are lazy
|
||||
// and can run before top-level `let` is initialized, since imports are
|
||||
// hoisted above variable declarations.
|
||||
const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/login') }));
|
||||
|
||||
vi.mock('$app/state', () => ({
|
||||
page: {
|
||||
get url() { return state.pageUrl; }
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('$app/navigation', () => ({
|
||||
goto: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/auth/store.svelte', () => ({
|
||||
login: vi.fn(),
|
||||
user: { value: null }
|
||||
}));
|
||||
|
||||
import LoginPage from './+page.svelte';
|
||||
import { login } from '$lib/auth/store.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.pageUrl = new URL('http://localhost/login');
|
||||
});
|
||||
|
||||
async function submit(username = 'alice', password = 'pw') {
|
||||
const u = screen.getByLabelText(/username/i) as HTMLInputElement;
|
||||
const p = screen.getByLabelText(/password/i) as HTMLInputElement;
|
||||
await fireEvent.input(u, { target: { value: username } });
|
||||
await fireEvent.input(p, { target: { value: password } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
|
||||
}
|
||||
|
||||
describe('login page', () => {
|
||||
test('renders username, password, and sign-in button', () => {
|
||||
render(LoginPage);
|
||||
expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('invalid credentials show inline error and clear password', async () => {
|
||||
(login as ReturnType<typeof vi.fn>).mockRejectedValue({
|
||||
code: 'invalid_credentials', message: 'bad creds', status: 401
|
||||
});
|
||||
render(LoginPage);
|
||||
await submit();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/invalid username or password/i);
|
||||
});
|
||||
expect((screen.getByLabelText(/password/i) as HTMLInputElement).value).toBe('');
|
||||
expect((screen.getByLabelText(/username/i) as HTMLInputElement).value).toBe('alice');
|
||||
});
|
||||
|
||||
test('server error shows generic message and preserves both fields', async () => {
|
||||
(login as ReturnType<typeof vi.fn>).mockRejectedValue({
|
||||
code: 'server_error', message: 'boom', status: 500
|
||||
});
|
||||
render(LoginPage);
|
||||
await submit('alice', 'pw');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/try again in a moment/i);
|
||||
});
|
||||
expect((screen.getByLabelText(/password/i) as HTMLInputElement).value).toBe('pw');
|
||||
expect((screen.getByLabelText(/username/i) as HTMLInputElement).value).toBe('alice');
|
||||
});
|
||||
|
||||
test('success navigates to returnTo when safe', async () => {
|
||||
state.pageUrl = new URL('http://localhost/login?returnTo=/artists/abc');
|
||||
(login as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
render(LoginPage);
|
||||
await submit();
|
||||
await waitFor(() => {
|
||||
expect(goto).toHaveBeenCalledWith('/artists/abc', { replaceState: true });
|
||||
});
|
||||
});
|
||||
|
||||
test('success falls back to "/" when returnTo is missing', async () => {
|
||||
(login as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
render(LoginPage);
|
||||
await submit();
|
||||
await waitFor(() => {
|
||||
expect(goto).toHaveBeenCalledWith('/', { replaceState: true });
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
['//evil.com', 'protocol-relative'],
|
||||
['http://evil.com', 'absolute URL'],
|
||||
['/login', 'self-redirect'],
|
||||
['../admin', 'parent traversal']
|
||||
])('rejects unsafe returnTo %s (%s) and falls back to "/"', async (value) => {
|
||||
state.pageUrl = new URL(`http://localhost/login?returnTo=${encodeURIComponent(value)}`);
|
||||
(login as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
render(LoginPage);
|
||||
await submit();
|
||||
await waitFor(() => {
|
||||
expect(goto).toHaveBeenCalledWith('/', { replaceState: true });
|
||||
});
|
||||
});
|
||||
|
||||
test('submit button disables and marks aria-busy while pending', async () => {
|
||||
let release!: () => void;
|
||||
(login as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
new Promise<void>((resolve) => { release = () => resolve(); })
|
||||
);
|
||||
render(LoginPage);
|
||||
await submit();
|
||||
const btn = screen.getByRole('button', { name: /sign in/i });
|
||||
expect(btn).toBeDisabled();
|
||||
expect(btn).toHaveAttribute('aria-busy', 'true');
|
||||
release();
|
||||
await waitFor(() => expect(btn).not.toBeDisabled());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
<h1 class="text-2xl font-semibold">Playlists</h1>
|
||||
<p class="mt-2 text-text-secondary">Playlists land in a subsequent plan.</p>
|
||||
@@ -0,0 +1,2 @@
|
||||
<h1 class="text-2xl font-semibold">Search</h1>
|
||||
<p class="mt-2 text-text-secondary">Search lands in a subsequent plan.</p>
|
||||
+21
-2
@@ -9,6 +9,25 @@
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["node", "vitest"]
|
||||
},
|
||||
"include": [
|
||||
".svelte-kit/ambient.d.ts",
|
||||
".svelte-kit/non-ambient.d.ts",
|
||||
".svelte-kit/types/**/$types.d.ts",
|
||||
"vite.config.js",
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts",
|
||||
"vitest.setup.ts",
|
||||
"src/**/*.js",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.svelte",
|
||||
"test/**/*.js",
|
||||
"test/**/*.ts",
|
||||
"test/**/*.svelte",
|
||||
"tests/**/*.js",
|
||||
"tests/**/*.ts",
|
||||
"tests/**/*.svelte"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { svelteTesting } from '@testing-library/svelte/vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
plugins: [sveltekit(), svelteTesting()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.test.ts']
|
||||
include: ['src/**/*.test.ts'],
|
||||
setupFiles: ['./vitest.setup.ts']
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
Reference in New Issue
Block a user