@svgrid/create 2.7.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 (29) hide show
  1. package/index.mjs +5 -1
  2. package/package.json +2 -2
  3. package/templates/pivot-dashboard/README.md +74 -0
  4. package/templates/pivot-dashboard/_gitignore +23 -0
  5. package/templates/pivot-dashboard/_package.json +27 -0
  6. package/templates/pivot-dashboard/src/app.css +15 -0
  7. package/templates/pivot-dashboard/src/app.d.ts +11 -0
  8. package/templates/pivot-dashboard/src/app.html +22 -0
  9. package/templates/pivot-dashboard/src/lib/drill.ts +141 -0
  10. package/templates/pivot-dashboard/src/lib/facts.ts +81 -0
  11. package/templates/pivot-dashboard/src/lib/theme.svelte.ts +87 -0
  12. package/templates/pivot-dashboard/src/routes/+layout.svelte +79 -0
  13. package/templates/pivot-dashboard/src/routes/+page.server.ts +11 -0
  14. package/templates/pivot-dashboard/src/routes/+page.svelte +138 -0
  15. package/templates/pivot-dashboard/src/routes/DrillRail.svelte +82 -0
  16. package/templates/pivot-dashboard/src/routes/TrendChart.svelte +62 -0
  17. package/templates/pivot-dashboard/tsconfig.json +20 -0
  18. package/templates/pivot-dashboard/vite.config.ts +20 -0
  19. package/templates/sveltekit/README.md +33 -3
  20. package/templates/sveltekit/src/app.d.ts +10 -2
  21. package/templates/sveltekit/src/hooks.server.ts +49 -0
  22. package/templates/sveltekit/src/lib/server/auth.ts +177 -0
  23. package/templates/sveltekit/src/routes/+layout.server.ts +10 -0
  24. package/templates/sveltekit/src/routes/+layout.svelte +16 -1
  25. package/templates/sveltekit/src/routes/login/+page.server.ts +51 -0
  26. package/templates/sveltekit/src/routes/login/+page.svelte +55 -0
  27. package/templates/sveltekit/src/routes/logout/+server.ts +24 -0
  28. package/templates/sveltekit/src/routes/people/+page.server.ts +12 -3
  29. package/templates/sveltekit/src/routes/people/+page.svelte +10 -5
@@ -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}