@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.
- package/README.md +11 -5
- package/index.mjs +486 -466
- package/package.json +2 -2
- package/templates/headless/README.md +65 -0
- package/templates/headless/_gitignore +4 -0
- package/templates/headless/_package.json +22 -0
- package/templates/headless/index.html +12 -0
- package/templates/headless/src/App.svelte +159 -0
- package/templates/headless/src/app.css +153 -0
- package/templates/headless/src/main.ts +7 -0
- package/templates/headless/src/vite-env.d.ts +2 -0
- package/templates/headless/svelte.config.js +5 -0
- package/templates/headless/tsconfig.json +14 -0
- package/templates/headless/vite.config.js +6 -0
- package/templates/minimal/src/app.css +1 -1
- package/templates/pivot-dashboard/README.md +74 -0
- package/templates/pivot-dashboard/_gitignore +23 -0
- package/templates/pivot-dashboard/_package.json +27 -0
- package/templates/pivot-dashboard/src/app.css +15 -0
- package/templates/pivot-dashboard/src/app.d.ts +11 -0
- package/templates/pivot-dashboard/src/app.html +22 -0
- package/templates/pivot-dashboard/src/lib/drill.ts +141 -0
- package/templates/pivot-dashboard/src/lib/facts.ts +81 -0
- package/templates/pivot-dashboard/src/lib/theme.svelte.ts +87 -0
- package/templates/pivot-dashboard/src/routes/+layout.svelte +79 -0
- package/templates/pivot-dashboard/src/routes/+page.server.ts +11 -0
- package/templates/pivot-dashboard/src/routes/+page.svelte +138 -0
- package/templates/pivot-dashboard/src/routes/DrillRail.svelte +82 -0
- package/templates/pivot-dashboard/src/routes/TrendChart.svelte +62 -0
- package/templates/pivot-dashboard/tsconfig.json +20 -0
- package/templates/pivot-dashboard/vite.config.ts +20 -0
- package/templates/sveltekit/README.md +33 -3
- package/templates/sveltekit/src/app.css +1 -1
- package/templates/sveltekit/src/app.d.ts +10 -2
- package/templates/sveltekit/src/hooks.server.ts +49 -0
- package/templates/sveltekit/src/lib/server/auth.ts +177 -0
- package/templates/sveltekit/src/lib/theme.svelte.ts +1 -1
- package/templates/sveltekit/src/routes/+layout.server.ts +10 -0
- package/templates/sveltekit/src/routes/+layout.svelte +16 -1
- package/templates/sveltekit/src/routes/login/+page.server.ts +51 -0
- package/templates/sveltekit/src/routes/login/+page.svelte +55 -0
- package/templates/sveltekit/src/routes/logout/+server.ts +24 -0
- package/templates/sveltekit/src/routes/people/+page.server.ts +12 -3
- 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,
|
|
4
|
-
|
|
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/
|
|
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
|
-
|
|
7
|
-
|
|
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 = '
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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>
|
|
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}
|