@svgrid/create 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +11 -5
  2. package/index.mjs +486 -466
  3. package/package.json +2 -2
  4. package/templates/headless/README.md +65 -0
  5. package/templates/headless/_gitignore +4 -0
  6. package/templates/headless/_package.json +22 -0
  7. package/templates/headless/index.html +12 -0
  8. package/templates/headless/src/App.svelte +159 -0
  9. package/templates/headless/src/app.css +153 -0
  10. package/templates/headless/src/main.ts +7 -0
  11. package/templates/headless/src/vite-env.d.ts +2 -0
  12. package/templates/headless/svelte.config.js +5 -0
  13. package/templates/headless/tsconfig.json +14 -0
  14. package/templates/headless/vite.config.js +6 -0
  15. package/templates/minimal/src/app.css +1 -1
  16. package/templates/pivot-dashboard/README.md +74 -0
  17. package/templates/pivot-dashboard/_gitignore +23 -0
  18. package/templates/pivot-dashboard/_package.json +27 -0
  19. package/templates/pivot-dashboard/src/app.css +15 -0
  20. package/templates/pivot-dashboard/src/app.d.ts +11 -0
  21. package/templates/pivot-dashboard/src/app.html +22 -0
  22. package/templates/pivot-dashboard/src/lib/drill.ts +141 -0
  23. package/templates/pivot-dashboard/src/lib/facts.ts +81 -0
  24. package/templates/pivot-dashboard/src/lib/theme.svelte.ts +87 -0
  25. package/templates/pivot-dashboard/src/routes/+layout.svelte +79 -0
  26. package/templates/pivot-dashboard/src/routes/+page.server.ts +11 -0
  27. package/templates/pivot-dashboard/src/routes/+page.svelte +138 -0
  28. package/templates/pivot-dashboard/src/routes/DrillRail.svelte +82 -0
  29. package/templates/pivot-dashboard/src/routes/TrendChart.svelte +62 -0
  30. package/templates/pivot-dashboard/tsconfig.json +20 -0
  31. package/templates/pivot-dashboard/vite.config.ts +20 -0
  32. package/templates/sveltekit/README.md +33 -3
  33. package/templates/sveltekit/src/app.css +1 -1
  34. package/templates/sveltekit/src/app.d.ts +10 -2
  35. package/templates/sveltekit/src/hooks.server.ts +49 -0
  36. package/templates/sveltekit/src/lib/server/auth.ts +177 -0
  37. package/templates/sveltekit/src/lib/theme.svelte.ts +1 -1
  38. package/templates/sveltekit/src/routes/+layout.server.ts +10 -0
  39. package/templates/sveltekit/src/routes/+layout.svelte +16 -1
  40. package/templates/sveltekit/src/routes/login/+page.server.ts +51 -0
  41. package/templates/sveltekit/src/routes/login/+page.svelte +55 -0
  42. package/templates/sveltekit/src/routes/logout/+server.ts +24 -0
  43. package/templates/sveltekit/src/routes/people/+page.server.ts +12 -3
  44. package/templates/sveltekit/src/routes/people/+page.svelte +10 -5
@@ -0,0 +1,20 @@
1
+ {
2
+ "extends": "./.svelte-kit/tsconfig.json",
3
+ "compilerOptions": {
4
+ "rewriteRelativeImportExtensions": true,
5
+ "allowJs": true,
6
+ "checkJs": true,
7
+ "esModuleInterop": true,
8
+ "forceConsistentCasingInFileNames": true,
9
+ "resolveJsonModule": true,
10
+ "skipLibCheck": true,
11
+ "sourceMap": true,
12
+ "strict": true,
13
+ "moduleResolution": "bundler"
14
+ }
15
+ // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
16
+ // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
17
+ //
18
+ // To make changes to top-level options such as include and exclude, we recommend extending
19
+ // the generated config; see https://svelte.dev/docs/kit/configuration#typescript
20
+ }
@@ -0,0 +1,20 @@
1
+ import adapter from '@sveltejs/adapter-auto';
2
+ import { sveltekit } from '@sveltejs/kit/vite';
3
+ import { defineConfig } from 'vite';
4
+
5
+ export default defineConfig({
6
+ plugins: [
7
+ sveltekit({
8
+ compilerOptions: {
9
+ // Force runes mode for the project, except for libraries. Can be removed in svelte 6.
10
+ runes: ({ filename }) =>
11
+ filename.split(/[/\\]/).includes('node_modules') ? undefined : true
12
+ },
13
+
14
+ // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
15
+ // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
16
+ // See https://svelte.dev/docs/kit/adapters for more information about adapters.
17
+ adapter: adapter()
18
+ })
19
+ ]
20
+ });
@@ -1,8 +1,8 @@
1
1
  # SvGrid + SvelteKit sample
2
2
 
3
- A grid whose rows are loaded on the server, sorted from the URL, and edited
4
- through a form action - the three things that are different about running a grid
5
- in SvelteKit rather than a plain Vite SPA.
3
+ A grid whose rows are loaded on the server, sorted from the URL, edited through
4
+ a form action, and gated behind a cookie session with roles - the things that
5
+ are different about running a grid in SvelteKit rather than a plain Vite SPA.
6
6
 
7
7
  ```bash
8
8
  npm install
@@ -19,6 +19,16 @@ npm run dev # http://localhost:5173/people
19
19
  light and dark, applied live.
20
20
  4. **`curl localhost:5173/people`.** The rows are in the HTML, not injected by
21
21
  JS afterwards. That is what a crawler sees.
22
+ 5. **Sign in as each user.** `admin@example.com` can edit names;
23
+ `viewer@example.com` cannot, and the column is not editable for them.
24
+ The password is `password` for both.
25
+ 6. **Try to bypass the gate.** Sign in as the viewer, then post the action by
26
+ hand. It comes back 403, because the check is on the server and hiding the
27
+ button was only cosmetic:
28
+
29
+ ```bash
30
+ curl -i -X POST 'localhost:5173/people?/rename' -F id=1 -F name=Nope
31
+ ```
22
32
 
23
33
  ## Where things are
24
34
 
@@ -27,9 +37,29 @@ npm run dev # http://localhost:5173/people
27
37
  | `src/lib/people.ts` | Stands in for your database. Swap for real queries. |
28
38
  | `src/routes/people/+page.server.ts` | `load` sorts from the query string; the `rename` action takes the edit. |
29
39
  | `src/routes/people/+page.svelte` | The grid. `externalSort` because the server owns the ordering. |
40
+ | `src/lib/server/auth.ts` | Password hashing (PBKDF2 via Web Crypto), sessions, roles. Swap the arrays for your database. |
41
+ | `src/hooks.server.ts` | Resolves the session once per request and gates routes from one list. |
42
+ | `src/routes/login/+page.server.ts` | Login action. Rejects open redirects; one message for every failure. |
43
+ | `src/routes/logout/+server.ts` | POST-only sign out. Drops the session server-side, not just the cookie. |
30
44
  | `src/lib/theme.svelte.ts` | Runtime theme switching via `resolveThemeTokens`. |
31
45
  | `src/app.css` | Imports one preset so the first paint is themed before JS runs. |
32
46
 
47
+ ## Auth
48
+
49
+ The scaffold is the real shape, with the storage stubbed:
50
+
51
+ - Passwords are hashed with PBKDF2-HMAC-SHA256 through Web Crypto, so the same
52
+ code runs on Node and on the edge runtimes `adapter-auto` may pick.
53
+ - The session cookie is `httpOnly` (an XSS bug cannot read it) and
54
+ `SameSite=Lax` (a cross-site POST cannot ride it).
55
+ - Sign out drops the session server-side, so a copied id stops working.
56
+ - Route gating lives in one list in `hooks.server.ts`, so adding a protected
57
+ route is an entry rather than a check you have to remember.
58
+
59
+ The users and sessions are in-memory, like `src/lib/people.ts`. Move both to
60
+ your database before this goes anywhere real - in particular, an in-memory
61
+ session map does not survive a restart or a second instance.
62
+
33
63
  ## Themes
34
64
 
35
65
  Pick a starting theme when you scaffold:
@@ -8,7 +8,7 @@
8
8
  * `npm create @svgrid@latest -- --theme <id>` rewrites the line between the
9
9
  * markers, so pick a starting theme at scaffold time if you prefer. */
10
10
  /* svgrid-theme:start */
11
- @import '@svgrid/grid/themes/tailwind.css';
11
+ @import '@svgrid/grid/themes/ember.css';
12
12
  /* svgrid-theme:end */
13
13
 
14
14
  * { box-sizing: border-box; }
@@ -1,10 +1,18 @@
1
1
  // See https://svelte.dev/docs/kit/types#app.d.ts
2
2
  // for information about these interfaces
3
+ import type { User } from '$lib/server/auth'
4
+
3
5
  declare global {
4
6
  namespace App {
5
7
  // interface Error {}
6
- // interface Locals {}
7
- // interface PageData {}
8
+ interface Locals {
9
+ /** Set by hooks.server.ts on every request; null when signed out. */
10
+ user: User | null
11
+ }
12
+ interface PageData {
13
+ /** Returned by the root layout load, so every page can read it. */
14
+ user?: User | null
15
+ }
8
16
  // interface PageState {}
9
17
  // interface Platform {}
10
18
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Server hooks: resolve the session once per request, then gate routes.
3
+ *
4
+ * Doing it here rather than in each `+page.server.ts` is what makes the gate
5
+ * hard to forget - a new protected route is one entry in PROTECTED, not a
6
+ * check you have to remember to copy into every load function.
7
+ */
8
+ import { redirect, type Handle } from '@sveltejs/kit'
9
+ import { SESSION_COOKIE, userForSession } from '$lib/server/auth'
10
+ import type { Role } from '$lib/server/auth'
11
+
12
+ /**
13
+ * Route prefixes that need a session, and the role they need.
14
+ *
15
+ * `null` means any signed-in user. Longest prefix wins, so a specific child
16
+ * route can require more than its parent.
17
+ */
18
+ const PROTECTED: { prefix: string; role: Role | null }[] = [
19
+ { prefix: '/people', role: null },
20
+ { prefix: '/admin', role: 'admin' },
21
+ ]
22
+
23
+ function requirementFor(pathname: string): Role | null | undefined {
24
+ const match = PROTECTED.filter((r) => pathname === r.prefix || pathname.startsWith(r.prefix + '/')).sort(
25
+ (a, b) => b.prefix.length - a.prefix.length,
26
+ )[0]
27
+ return match ? match.role : undefined
28
+ }
29
+
30
+ export const handle: Handle = async ({ event, resolve }) => {
31
+ event.locals.user = await userForSession(event.cookies.get(SESSION_COOKIE))
32
+
33
+ const required = requirementFor(event.url.pathname)
34
+ if (required !== undefined) {
35
+ if (!event.locals.user) {
36
+ // Carry where they were going, so login can send them back rather than
37
+ // dumping everyone on the home page.
38
+ const from = encodeURIComponent(event.url.pathname + event.url.search)
39
+ redirect(303, `/login?redirectTo=${from}`)
40
+ }
41
+ if (required && event.locals.user.role !== required) {
42
+ // Signed in but not allowed: 403, not a redirect to login. Bouncing an
43
+ // authenticated user to a login form reads as a broken app.
44
+ redirect(303, '/people?error=forbidden')
45
+ }
46
+ }
47
+
48
+ return resolve(event)
49
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Auth scaffold: password hashing, sessions, and role lookup.
3
+ *
4
+ * Everything here runs on the server only - `$lib/server` is a folder SvelteKit
5
+ * enforces, so importing this from a component is a build error rather than a
6
+ * leaked password hash.
7
+ *
8
+ * Like `$lib/people.ts`, the stores are module-level and in-memory: they stand
9
+ * in for your database so the starter runs with no setup. Swap the two arrays
10
+ * for real queries and nothing else has to change. What is *not* a placeholder
11
+ * is the hashing and the cookie handling - those are the real patterns, because
12
+ * getting them wrong is the expensive kind of wrong.
13
+ *
14
+ * Built on Web Crypto rather than `node:crypto`, so the same code runs on Node,
15
+ * Deno, Bun and the edge runtimes `adapter-auto` may select. Nothing here needs
16
+ * a dependency.
17
+ */
18
+
19
+ export type Role = 'admin' | 'viewer'
20
+ export type User = { id: number; email: string; role: Role }
21
+
22
+ /** A user plus the credential we never hand to the client. */
23
+ type StoredUser = User & { passwordHash: string }
24
+
25
+ /** Name of the session cookie. */
26
+ export const SESSION_COOKIE = 'sid'
27
+
28
+ /** How long a session stays valid. Refreshed on each request. */
29
+ const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7 // 7 days
30
+
31
+ /** PBKDF2 rounds. OWASP's floor for PBKDF2-HMAC-SHA256 is 600k; this is the
32
+ * knob to raise as hardware gets faster. Stored alongside each hash so old
33
+ * hashes keep verifying after you raise it. */
34
+ const PBKDF2_ROUNDS = 600_000
35
+
36
+ const encoder = new TextEncoder()
37
+
38
+ function toHex(bytes: Uint8Array): string {
39
+ return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')
40
+ }
41
+
42
+ function fromHex(hex: string): Uint8Array {
43
+ const out = new Uint8Array(hex.length / 2)
44
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
45
+ return out
46
+ }
47
+
48
+ async function derive(password: string, salt: Uint8Array, rounds: number): Promise<Uint8Array> {
49
+ const key = await crypto.subtle.importKey('raw', encoder.encode(password), 'PBKDF2', false, ['deriveBits'])
50
+ const bits = await crypto.subtle.deriveBits(
51
+ { name: 'PBKDF2', salt: salt as BufferSource, iterations: rounds, hash: 'SHA-256' },
52
+ key,
53
+ 256,
54
+ )
55
+ return new Uint8Array(bits)
56
+ }
57
+
58
+ /** Format: rounds:salt:hash, so the work factor travels with the hash. */
59
+ async function hashPassword(password: string): Promise<string> {
60
+ const salt = crypto.getRandomValues(new Uint8Array(16))
61
+ const hash = await derive(password, salt, PBKDF2_ROUNDS)
62
+ return `${PBKDF2_ROUNDS}:${toHex(salt)}:${toHex(hash)}`
63
+ }
64
+
65
+ /** Compare without an early exit, so the time taken does not reveal how many
66
+ * leading bytes matched. */
67
+ function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
68
+ if (a.length !== b.length) return false
69
+ let diff = 0
70
+ for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!
71
+ return diff === 0
72
+ }
73
+
74
+ async function verifyPassword(password: string, stored: string): Promise<boolean> {
75
+ const [rounds, salt, hash] = stored.split(':')
76
+ if (!rounds || !salt || !hash) return false
77
+ const actual = await derive(password, fromHex(salt), Number(rounds))
78
+ return constantTimeEqual(actual, fromHex(hash))
79
+ }
80
+
81
+ // --- users -----------------------------------------------------------------
82
+
83
+ /**
84
+ * Seeded lazily: hashing is async, and 600k PBKDF2 rounds twice at import would
85
+ * stall the first request. `getUsers()` awaits the same promise every time, so
86
+ * the work happens once.
87
+ */
88
+ let usersPromise: Promise<StoredUser[]> | null = null
89
+
90
+ function getUsers(): Promise<StoredUser[]> {
91
+ usersPromise ??= (async () => [
92
+ { id: 1, email: 'admin@example.com', role: 'admin' as const, passwordHash: await hashPassword('password') },
93
+ { id: 2, email: 'viewer@example.com', role: 'viewer' as const, passwordHash: await hashPassword('password') },
94
+ ])()
95
+ return usersPromise
96
+ }
97
+
98
+ /** Hashed once so an unknown email costs the same work as a known one. Without
99
+ * it, a fast "no such user" reply enumerates your user list. */
100
+ let dummyHashPromise: Promise<string> | null = null
101
+ function getDummyHash(): Promise<string> {
102
+ dummyHashPromise ??= hashPassword(crypto.randomUUID())
103
+ return dummyHashPromise
104
+ }
105
+
106
+ /** Public shape - never includes the hash. */
107
+ function publicUser(u: StoredUser): User {
108
+ return { id: u.id, email: u.email, role: u.role }
109
+ }
110
+
111
+ /**
112
+ * Check an email/password pair. Returns the user, or null.
113
+ *
114
+ * The same null comes back for an unknown email and a wrong password, and a
115
+ * verify runs either way, so the response neither confirms which emails exist
116
+ * nor answers faster for one case than the other.
117
+ */
118
+ export async function verifyCredentials(email: string, password: string): Promise<User | null> {
119
+ const users = await getUsers()
120
+ const found = users.find((u) => u.email.toLowerCase() === email.trim().toLowerCase())
121
+ const ok = await verifyPassword(password, found?.passwordHash ?? (await getDummyHash()))
122
+ return ok && found ? publicUser(found) : null
123
+ }
124
+
125
+ // --- sessions --------------------------------------------------------------
126
+
127
+ type Session = { userId: number; expires: number }
128
+
129
+ const sessions = new Map<string, Session>()
130
+
131
+ /** Start a session and return its id, to be set as the cookie value. */
132
+ export function createSession(userId: number): string {
133
+ // randomUUID is a CSPRNG. A guessable session id is as good as no auth.
134
+ const id = crypto.randomUUID()
135
+ sessions.set(id, { userId, expires: Date.now() + SESSION_TTL_MS })
136
+ return id
137
+ }
138
+
139
+ /** Resolve a session id to its user, sliding the expiry forward. Returns null
140
+ * for an unknown or expired id, and drops the expired entry. */
141
+ export async function userForSession(id: string | undefined): Promise<User | null> {
142
+ if (!id) return null
143
+ const session = sessions.get(id)
144
+ if (!session) return null
145
+ if (session.expires < Date.now()) {
146
+ sessions.delete(id)
147
+ return null
148
+ }
149
+ session.expires = Date.now() + SESSION_TTL_MS
150
+ const users = await getUsers()
151
+ const user = users.find((u) => u.id === session.userId)
152
+ return user ? publicUser(user) : null
153
+ }
154
+
155
+ /** Invalidate a session server-side. Clearing the cookie alone would leave a
156
+ * stolen id working until it expired. */
157
+ export function destroySession(id: string | undefined): void {
158
+ if (id) sessions.delete(id)
159
+ }
160
+
161
+ /**
162
+ * Cookie options for the session.
163
+ *
164
+ * `httpOnly` keeps the id away from JavaScript, so an XSS bug cannot read it.
165
+ * `sameSite: 'lax'` blocks the cookie on cross-site POSTs, which is what stops
166
+ * CSRF against the form actions. `secure` is on outside dev, where there is no
167
+ * HTTPS to require.
168
+ */
169
+ export function sessionCookieOptions(secure: boolean) {
170
+ return {
171
+ path: '/',
172
+ httpOnly: true,
173
+ sameSite: 'lax',
174
+ secure,
175
+ maxAge: Math.floor(SESSION_TTL_MS / 1000),
176
+ } as const
177
+ }
@@ -28,7 +28,7 @@ const STORAGE_KEY = 'svgrid-theme'
28
28
  // so does the OS preference when nobody has pinned a mode. The inline script in
29
29
  // `app.html` settles the same question before the first paint.
30
30
  /* svgrid-initial-theme:start */
31
- export const INITIAL_THEME = 'tailwind'
31
+ export const INITIAL_THEME = 'ember'
32
32
  export const INITIAL_MODE: ThemeMode = 'light'
33
33
  /* svgrid-initial-theme:end */
34
34
 
@@ -0,0 +1,10 @@
1
+ import type { LayoutServerLoad } from './$types'
2
+
3
+ /**
4
+ * Publish the signed-in user to every page.
5
+ *
6
+ * `locals.user` is server-only; returning it from a layout load is what makes
7
+ * it available to components as `data.user`. Only the public shape crosses
8
+ * that boundary - `$lib/server/auth` never puts the password hash on it.
9
+ */
10
+ export const load: LayoutServerLoad = ({ locals }) => ({ user: locals.user })
@@ -2,7 +2,7 @@
2
2
  import '../app.css'
3
3
  import { theme, presets } from '$lib/theme.svelte'
4
4
 
5
- let { children } = $props()
5
+ let { children, data } = $props()
6
6
 
7
7
  // Apply on mount so the picker's starting value wins over the stylesheet, and
8
8
  // on every later change. Runs client-side only - the server-rendered HTML is
@@ -31,6 +31,14 @@
31
31
  <button type="button" onclick={() => theme.toggleMode()} aria-pressed={theme.mode === 'dark'}>
32
32
  {theme.mode === 'dark' ? 'Dark' : 'Light'}
33
33
  </button>
34
+
35
+ {#if data?.user}
36
+ <span class="who">{data.user.email} <em>({data.user.role})</em></span>
37
+ <!-- A form, not a link: see routes/logout/+page.server.ts for why. -->
38
+ <form class="out" method="POST" action="/logout"><button type="submit">Sign out</button></form>
39
+ {:else}
40
+ <a class="who" href="/login">Sign in</a>
41
+ {/if}
34
42
  </header>
35
43
 
36
44
  <main>
@@ -68,6 +76,13 @@
68
76
  cursor: pointer;
69
77
  min-width: 4.5rem;
70
78
  }
79
+ .who {
80
+ margin-inline-start: auto;
81
+ font-size: 0.875rem;
82
+ color: var(--sg-header-fg);
83
+ }
84
+ .who em { color: var(--sg-muted, #64748b); font-style: normal; }
85
+ .out { margin: 0; }
71
86
  main {
72
87
  padding: 1.25rem;
73
88
  font-family: system-ui, sans-serif;
@@ -0,0 +1,51 @@
1
+ import { fail, redirect } from '@sveltejs/kit'
2
+ import { dev } from '$app/environment'
3
+ import type { Actions, PageServerLoad } from './$types'
4
+ import {
5
+ SESSION_COOKIE,
6
+ createSession,
7
+ sessionCookieOptions,
8
+ verifyCredentials,
9
+ } from '$lib/server/auth'
10
+
11
+ export const load: PageServerLoad = ({ locals, url }) => {
12
+ // Already signed in? Nothing to do here.
13
+ if (locals.user) redirect(303, safeRedirect(url.searchParams.get('redirectTo')))
14
+ return { redirectTo: safeRedirect(url.searchParams.get('redirectTo')) }
15
+ }
16
+
17
+ /**
18
+ * Only ever redirect to a path on this site. Echoing an arbitrary `redirectTo`
19
+ * back into a Location header is an open redirect: an attacker mails a link to
20
+ * your real login page that lands the user on theirs afterwards.
21
+ */
22
+ function safeRedirect(target: string | null): string {
23
+ if (!target) return '/people'
24
+ // Must start with a single slash - "//evil.test" and "https://evil.test" are
25
+ // both absolute, and the browser would happily follow them off-site.
26
+ if (!target.startsWith('/') || target.startsWith('//')) return '/people'
27
+ return target
28
+ }
29
+
30
+ export const actions: Actions = {
31
+ default: async ({ request, cookies, url }) => {
32
+ const data = await request.formData()
33
+ const email = String(data.get('email') ?? '')
34
+ const password = String(data.get('password') ?? '')
35
+ const redirectTo = safeRedirect(String(data.get('redirectTo') ?? '') || url.searchParams.get('redirectTo'))
36
+
37
+ if (!email || !password) {
38
+ return fail(400, { email, error: 'Enter your email and password.' })
39
+ }
40
+
41
+ const user = await verifyCredentials(email, password)
42
+ if (!user) {
43
+ // One message for both "no such user" and "wrong password": a specific
44
+ // one tells an attacker which emails are registered.
45
+ return fail(401, { email, error: 'Those credentials did not match.' })
46
+ }
47
+
48
+ cookies.set(SESSION_COOKIE, createSession(user.id), sessionCookieOptions(!dev))
49
+ redirect(303, redirectTo)
50
+ },
51
+ }
@@ -0,0 +1,55 @@
1
+ <script lang="ts">
2
+ import { enhance } from '$app/forms'
3
+ import type { ActionData, PageData } from './$types'
4
+
5
+ let { data, form }: { data: PageData; form: ActionData } = $props()
6
+ </script>
7
+
8
+ <svelte:head><title>Sign in</title></svelte:head>
9
+
10
+ <main class="login">
11
+ <h1>Sign in</h1>
12
+
13
+ <form method="POST" use:enhance>
14
+ <input type="hidden" name="redirectTo" value={data.redirectTo} />
15
+
16
+ <label for="email">Email</label>
17
+ <input id="email" name="email" type="email" autocomplete="username" required value={form?.email ?? ''} />
18
+
19
+ <label for="password">Password</label>
20
+ <input id="password" name="password" type="password" autocomplete="current-password" required />
21
+
22
+ {#if form?.error}
23
+ <!-- Announced, so a screen reader hears the failure rather than only
24
+ seeing the field reset. -->
25
+ <p class="error" role="alert">{form.error}</p>
26
+ {/if}
27
+
28
+ <button type="submit">Sign in</button>
29
+ </form>
30
+
31
+ <p class="hint">
32
+ Demo users: <code>admin@example.com</code> (admin) and
33
+ <code>viewer@example.com</code> (viewer). Password <code>password</code> for both.
34
+ </p>
35
+ </main>
36
+
37
+ <style>
38
+ .login { max-width: 22rem; margin: 4rem auto; padding: 0 1rem; }
39
+ form { display: flex; flex-direction: column; gap: 0.35rem; margin-top: 1.5rem; }
40
+ label { font-weight: 600; font-size: 0.875rem; }
41
+ input {
42
+ padding: 0.5rem 0.6rem; font: inherit;
43
+ border: 1px solid var(--sg-border, #cbd5e1); border-radius: var(--sg-radius, 8px);
44
+ background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a);
45
+ }
46
+ input + label { margin-top: 0.75rem; }
47
+ button {
48
+ margin-top: 1.25rem; padding: 0.55rem 0.9rem; font: inherit; font-weight: 600;
49
+ color: #fff; background: var(--sg-accent, #2563eb);
50
+ border: 0; border-radius: var(--sg-radius, 8px); cursor: pointer;
51
+ }
52
+ .error { margin: 0.75rem 0 0; color: var(--sg-danger, #dc2626); font-size: 0.875rem; }
53
+ .hint { margin-top: 2rem; font-size: 0.8125rem; color: var(--sg-muted, #64748b); line-height: 1.6; }
54
+ code { font-size: 0.9em; }
55
+ </style>
@@ -0,0 +1,24 @@
1
+ import { redirect } from '@sveltejs/kit'
2
+ import { dev } from '$app/environment'
3
+ import type { RequestHandler } from './$types'
4
+ import { SESSION_COOKIE, destroySession, sessionCookieOptions } from '$lib/server/auth'
5
+
6
+ /**
7
+ * Sign out.
8
+ *
9
+ * An endpoint rather than a form action, because signing out has no page to
10
+ * render - a `+page.server.ts` with no `+page.svelte` beside it is not a route
11
+ * SvelteKit will post a form to, and returns 415.
12
+ *
13
+ * POST only, deliberately. A GET logout link fires from any prefetcher or
14
+ * `<img src="/logout">` on a page you do not control. SvelteKit's origin check
15
+ * covers this POST, and the session cookie is SameSite=Lax, so a cross-site
16
+ * form cannot drive it either.
17
+ */
18
+ export const POST: RequestHandler = async ({ cookies }) => {
19
+ // Drop the session server-side as well as clearing the cookie, so a copy of
20
+ // the id taken earlier stops working immediately.
21
+ destroySession(cookies.get(SESSION_COOKIE))
22
+ cookies.delete(SESSION_COOKIE, sessionCookieOptions(!dev))
23
+ redirect(303, '/login')
24
+ }
@@ -1,14 +1,23 @@
1
+ import { error } from '@sveltejs/kit'
1
2
  import type { Actions, PageServerLoad } from './$types'
2
3
  import { listPeople, renamePerson, type Person } from '$lib/people'
3
4
 
4
- export const load: PageServerLoad = ({ url }) => {
5
+ // hooks.server.ts already refused this request if nobody is signed in, so the
6
+ // load can assume a user. It still reads `locals.user` rather than trusting a
7
+ // query parameter or a client-sent id.
8
+ export const load: PageServerLoad = ({ url, locals }) => {
5
9
  const sortBy = (url.searchParams.get('sort') ?? 'name') as keyof Person
6
10
  const desc = url.searchParams.get('dir') === 'desc'
7
- return { rows: listPeople(sortBy, desc), sortBy, desc }
11
+ return { rows: listPeople(sortBy, desc), sortBy, desc, canEdit: locals.user?.role === 'admin' }
8
12
  }
9
13
 
10
14
  export const actions: Actions = {
11
- rename: async ({ request }) => {
15
+ rename: async ({ request, locals }) => {
16
+ // The check lives on the server. `canEdit` above only hides the UI, and
17
+ // hiding a button stops nobody from posting the form by hand.
18
+ if (locals.user?.role !== 'admin') {
19
+ error(403, 'Only an admin can rename people.')
20
+ }
12
21
  const data = await request.formData()
13
22
  renamePerson(Number(data.get('id')), String(data.get('name')))
14
23
  return { success: true }
@@ -6,11 +6,13 @@
6
6
 
7
7
  let { data } = $props()
8
8
 
9
- const columns: GridColumns<Person> = [
10
- { field: 'name', header: 'Name', editable: true },
9
+ // Editing is offered only to an admin. The server enforces it either way
10
+ // (see the rename action) - this just avoids showing an edit that will fail.
11
+ const columns: GridColumns<Person> = $derived([
12
+ { field: 'name', header: 'Name', editable: data.canEdit },
11
13
  { field: 'role', header: 'Role' },
12
14
  { field: 'year', header: 'Year' },
13
- ]
15
+ ])
14
16
 
15
17
  // Header click -> URL -> server sorts -> load returns ordered rows.
16
18
  function onSortingChange(sorting: Array<{ id: string; desc: boolean }>) {
@@ -27,7 +29,7 @@
27
29
 
28
30
  // Committed edit -> form action -> database.
29
31
  async function onCellValueChange(e: { row: Person; columnId: string; newValue: unknown }) {
30
- if (e.columnId !== 'name') return
32
+ if (e.columnId !== 'name' || !data.canEdit) return
31
33
  const body = new FormData()
32
34
  body.set('id', String(e.row.id))
33
35
  body.set('name', String(e.newValue))
@@ -36,7 +38,10 @@
36
38
  </script>
37
39
 
38
40
  <h1>People</h1>
39
- <p>Click a header to sort - the order lives in the URL. Double-click a name to edit it.</p>
41
+ <p>
42
+ Click a header to sort - the order lives in the URL.
43
+ {#if data.canEdit}Double-click a name to edit it.{:else}Sign in as an admin to edit names.{/if}
44
+ </p>
40
45
 
41
46
  <SvGrid
42
47
  data={data.rows}