@svgrid/create 2.7.0 → 3.0.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/index.mjs +5 -1
- package/package.json +2 -2
- 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/_package.json +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/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,138 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Pivot dashboard: one cube, one chart, one drill rail, all over the same
|
|
4
|
+
* facts.
|
|
5
|
+
*
|
|
6
|
+
* The point of the layout is that the three panes cannot disagree. The pivot
|
|
7
|
+
* summarises `facts`; the chart re-aggregates the same array along the first
|
|
8
|
+
* row dimension; the drill rail filters it back down to the rows behind one
|
|
9
|
+
* clicked cell. Change the layout in the designer and all three follow.
|
|
10
|
+
*/
|
|
11
|
+
import { SvPivotDesigner, setLicenseKey, type PivotField, type PivotLayout, type PivotRow } from '@svgrid/enterprise'
|
|
12
|
+
import { drillThrough, type Drill } from '$lib/drill'
|
|
13
|
+
import type { Fact } from '$lib/facts'
|
|
14
|
+
import DrillRail from './DrillRail.svelte'
|
|
15
|
+
import TrendChart from './TrendChart.svelte'
|
|
16
|
+
|
|
17
|
+
// Swap for your own key. Unlicensed use still runs - it just nudges.
|
|
18
|
+
setLicenseKey('SVENTERPRISE-DEV-DEMO')
|
|
19
|
+
|
|
20
|
+
let { data } = $props()
|
|
21
|
+
const facts = $derived(data.facts as Fact[])
|
|
22
|
+
|
|
23
|
+
const fields: PivotField[] = [
|
|
24
|
+
{ field: 'region', kind: 'dimension', label: 'Region' },
|
|
25
|
+
{ field: 'country', kind: 'dimension', label: 'Country' },
|
|
26
|
+
{ field: 'city', kind: 'dimension', label: 'City' },
|
|
27
|
+
{ field: 'channel', kind: 'dimension', label: 'Channel' },
|
|
28
|
+
{ field: 'customer', kind: 'dimension', label: 'Customer' },
|
|
29
|
+
{ field: 'year', kind: 'dimension', label: 'Year' },
|
|
30
|
+
{ field: 'quarter', kind: 'dimension', label: 'Quarter' },
|
|
31
|
+
{ field: 'revenue', kind: 'measure', label: 'Revenue' },
|
|
32
|
+
{ field: 'units', kind: 'measure', label: 'Units' },
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
let layout = $state<PivotLayout>({
|
|
36
|
+
rows: ['region', 'country'],
|
|
37
|
+
cols: ['year'],
|
|
38
|
+
values: [{ field: 'revenue', agg: 'sum' }],
|
|
39
|
+
// Required by PivotLayout. Empty means every fact passes.
|
|
40
|
+
filters: [],
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// The designer hands back the rows it rendered. Drill-through needs them to
|
|
44
|
+
// walk a clicked row up to its ancestors.
|
|
45
|
+
let pivotRows = $state<PivotRow[]>([])
|
|
46
|
+
let drill = $state<Drill | null>(null)
|
|
47
|
+
|
|
48
|
+
const money = (n: number) => n.toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })
|
|
49
|
+
const plain = (n: number) => n.toLocaleString()
|
|
50
|
+
const measure = $derived(layout.values[0]?.field ?? 'revenue')
|
|
51
|
+
const format = $derived(measure === 'units' ? plain : money)
|
|
52
|
+
|
|
53
|
+
/** Chart series: the active measure totalled by the first row dimension. */
|
|
54
|
+
const series = $derived.by(() => {
|
|
55
|
+
const dimension = layout.rows[0]
|
|
56
|
+
if (!dimension) return []
|
|
57
|
+
const totals = new Map<string, number>()
|
|
58
|
+
for (const fact of facts) {
|
|
59
|
+
const key = String((fact as unknown as Record<string, unknown>)[dimension])
|
|
60
|
+
const value = Number((fact as unknown as Record<string, unknown>)[measure] ?? 0)
|
|
61
|
+
totals.set(key, (totals.get(key) ?? 0) + value)
|
|
62
|
+
}
|
|
63
|
+
return [...totals].map(([label, value]) => ({ label, value })).sort((a, b) => b.value - a.value)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
/** The chart highlights whichever top-level value the drill is inside. */
|
|
67
|
+
const activeBar = $derived(drill && layout.rows[0] ? (drill.filter[layout.rows[0]] ?? null) : null)
|
|
68
|
+
|
|
69
|
+
function onCellClick(event: { row: PivotRow; columnId: string }) {
|
|
70
|
+
// The row-header column is a label, not an aggregation - nothing to drill.
|
|
71
|
+
if (event.columnId === '__pivotRowHeader') return
|
|
72
|
+
drill = drillThrough(facts, event.row, event.columnId, pivotRows, layout)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Clicking a bar drills the whole of that dimension value. */
|
|
76
|
+
function onBarSelect(label: string) {
|
|
77
|
+
const dimension = layout.rows[0]
|
|
78
|
+
if (!dimension) return
|
|
79
|
+
const matched = facts.filter((f) => String((f as unknown as Record<string, unknown>)[dimension]) === label)
|
|
80
|
+
drill = {
|
|
81
|
+
rowLabel: label,
|
|
82
|
+
filter: { [dimension]: label },
|
|
83
|
+
facts: matched,
|
|
84
|
+
measure,
|
|
85
|
+
total: matched.reduce((sum, f) => sum + Number((f as unknown as Record<string, unknown>)[measure] ?? 0), 0),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
</script>
|
|
89
|
+
|
|
90
|
+
<svelte:head><title>Pivot dashboard</title></svelte:head>
|
|
91
|
+
|
|
92
|
+
<h1>Sales cube</h1>
|
|
93
|
+
<p class="lede">
|
|
94
|
+
{plain(facts.length)} facts, summarised on the server and pivoted in the browser.
|
|
95
|
+
Click any aggregated cell to see the rows behind it.
|
|
96
|
+
</p>
|
|
97
|
+
|
|
98
|
+
<div class="layout" class:has-drill={drill !== null}>
|
|
99
|
+
<section class="cube" aria-label="Pivot">
|
|
100
|
+
<SvPivotDesigner
|
|
101
|
+
data={facts}
|
|
102
|
+
{fields}
|
|
103
|
+
bind:layout
|
|
104
|
+
expandable
|
|
105
|
+
panelPosition="right"
|
|
106
|
+
onPivot={(rows: PivotRow[]) => (pivotRows = rows)}
|
|
107
|
+
{onCellClick}
|
|
108
|
+
/>
|
|
109
|
+
</section>
|
|
110
|
+
|
|
111
|
+
<section class="chart" aria-label="Totals by {layout.rows[0] ?? 'dimension'}">
|
|
112
|
+
<h2>Total {measure} by {layout.rows[0] ?? 'dimension'}</h2>
|
|
113
|
+
<TrendChart {series} {format} active={activeBar} onSelect={onBarSelect} />
|
|
114
|
+
</section>
|
|
115
|
+
|
|
116
|
+
{#if drill}
|
|
117
|
+
<DrillRail {drill} {format} onClose={() => (drill = null)} />
|
|
118
|
+
{/if}
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<style>
|
|
122
|
+
h1 { margin: 0 0 0.25rem; font-size: 1.4rem; }
|
|
123
|
+
.lede { margin: 0 0 1.25rem; color: var(--sg-muted, #64748b); }
|
|
124
|
+
.layout { display: grid; gap: 1rem; grid-template-columns: 1fr; }
|
|
125
|
+
.cube { min-width: 0; height: 460px; }
|
|
126
|
+
.chart {
|
|
127
|
+
min-width: 0; padding: 1rem;
|
|
128
|
+
border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius, 8px);
|
|
129
|
+
}
|
|
130
|
+
.chart h2 { margin: 0 0 0.75rem; font-size: 0.95rem; text-transform: capitalize; }
|
|
131
|
+
/* Side by side once there is room. Below this the panes stack, which keeps
|
|
132
|
+
the pivot usable on a phone instead of squeezing three columns in. */
|
|
133
|
+
@media (min-width: 60rem) {
|
|
134
|
+
.layout { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
|
|
135
|
+
.cube { grid-column: 1 / -1; }
|
|
136
|
+
.layout.has-drill .cube { grid-column: 1 / -1; }
|
|
137
|
+
}
|
|
138
|
+
</style>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The drill-through rail: the facts behind one clicked pivot cell.
|
|
4
|
+
*
|
|
5
|
+
* The KPI at the top is recomputed from the same rows the grid below lists,
|
|
6
|
+
* so it always matches the cell that was clicked. If it ever did not, the
|
|
7
|
+
* drill filter and the pivot aggregation would have drifted apart.
|
|
8
|
+
*/
|
|
9
|
+
import { SvGrid, type GridColumns } from '@svgrid/grid'
|
|
10
|
+
import type { Drill } from '$lib/drill'
|
|
11
|
+
import { drillBreadcrumb } from '$lib/drill'
|
|
12
|
+
import type { Fact } from '$lib/facts'
|
|
13
|
+
|
|
14
|
+
type Props = {
|
|
15
|
+
drill: Drill
|
|
16
|
+
format: (value: number) => string
|
|
17
|
+
onClose: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let { drill, format, onClose }: Props = $props()
|
|
21
|
+
|
|
22
|
+
const columns: GridColumns<Fact> = [
|
|
23
|
+
{ field: 'customer', header: 'Customer' },
|
|
24
|
+
{ field: 'city', header: 'City' },
|
|
25
|
+
{ field: 'channel', header: 'Channel' },
|
|
26
|
+
{ field: 'quarter', header: 'Qtr' },
|
|
27
|
+
{ field: 'revenue', header: 'Revenue', align: 'right' },
|
|
28
|
+
{ field: 'units', header: 'Units', align: 'right' },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
const average = $derived(drill.facts.length ? drill.total / drill.facts.length : 0)
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<aside class="rail" aria-label="Drill-through">
|
|
35
|
+
<header>
|
|
36
|
+
<div>
|
|
37
|
+
<p class="eyebrow">Drill-through</p>
|
|
38
|
+
<h2>{drillBreadcrumb(drill)}</h2>
|
|
39
|
+
</div>
|
|
40
|
+
<button type="button" onclick={onClose} aria-label="Close drill-through">×</button>
|
|
41
|
+
</header>
|
|
42
|
+
|
|
43
|
+
<dl class="kpis">
|
|
44
|
+
<div><dt>{drill.measure}</dt><dd>{format(drill.total)}</dd></div>
|
|
45
|
+
<div><dt>Facts</dt><dd>{drill.facts.length.toLocaleString()}</dd></div>
|
|
46
|
+
<div><dt>Average</dt><dd>{format(Math.round(average))}</dd></div>
|
|
47
|
+
</dl>
|
|
48
|
+
|
|
49
|
+
<div class="grid">
|
|
50
|
+
<SvGrid data={drill.facts} {columns} sortable containerHeight={340} />
|
|
51
|
+
</div>
|
|
52
|
+
</aside>
|
|
53
|
+
|
|
54
|
+
<style>
|
|
55
|
+
.rail {
|
|
56
|
+
display: flex; flex-direction: column; gap: 1rem; min-width: 0;
|
|
57
|
+
border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius, 8px);
|
|
58
|
+
background: var(--sg-bg, #fff); padding: 1rem;
|
|
59
|
+
}
|
|
60
|
+
header { display: flex; align-items: start; justify-content: space-between; gap: 1rem; }
|
|
61
|
+
.eyebrow {
|
|
62
|
+
margin: 0 0 0.15rem; font-size: 0.6875rem; text-transform: uppercase;
|
|
63
|
+
letter-spacing: 0.06em; color: var(--sg-muted, #64748b);
|
|
64
|
+
}
|
|
65
|
+
h2 { margin: 0; font-size: 1rem; }
|
|
66
|
+
header button {
|
|
67
|
+
font: inherit; font-size: 1.25rem; line-height: 1; padding: 0.1rem 0.4rem; cursor: pointer;
|
|
68
|
+
background: none; border: 1px solid var(--sg-border, #e2e8f0);
|
|
69
|
+
border-radius: var(--sg-radius, 8px); color: var(--sg-fg, #0f172a);
|
|
70
|
+
}
|
|
71
|
+
.kpis { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem; margin: 0; }
|
|
72
|
+
.kpis div {
|
|
73
|
+
border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius, 8px);
|
|
74
|
+
padding: 0.5rem 0.6rem;
|
|
75
|
+
}
|
|
76
|
+
dt {
|
|
77
|
+
font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.05em;
|
|
78
|
+
color: var(--sg-muted, #64748b);
|
|
79
|
+
}
|
|
80
|
+
dd { margin: 0.15rem 0 0; font-size: 1rem; font-weight: 650; font-variant-numeric: tabular-nums; }
|
|
81
|
+
.grid { min-width: 0; }
|
|
82
|
+
</style>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* A bar chart over whatever slice the dashboard is currently showing.
|
|
4
|
+
*
|
|
5
|
+
* Inline SVG on purpose: a starter should not pull a charting library in for
|
|
6
|
+
* one chart, and this way the bars are plain DOM you can style with the same
|
|
7
|
+
* `--sg-*` tokens as the grid.
|
|
8
|
+
*/
|
|
9
|
+
type Props = {
|
|
10
|
+
/** Bars to draw, already aggregated. */
|
|
11
|
+
series: { label: string; value: number }[]
|
|
12
|
+
/** Formats the value in the tooltip and the axis. */
|
|
13
|
+
format: (value: number) => string
|
|
14
|
+
/** Highlighted bar, e.g. the one being drilled. */
|
|
15
|
+
active?: string | null
|
|
16
|
+
onSelect?: (label: string) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let { series, format, active = null, onSelect }: Props = $props()
|
|
20
|
+
|
|
21
|
+
const max = $derived(Math.max(1, ...series.map((s) => s.value)))
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
{#if series.length === 0}
|
|
25
|
+
<p class="empty">No data in this slice.</p>
|
|
26
|
+
{:else}
|
|
27
|
+
<ul class="bars">
|
|
28
|
+
{#each series as bar (bar.label)}
|
|
29
|
+
<li class="row" class:is-active={active === bar.label}>
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
onclick={() => onSelect?.(bar.label)}
|
|
33
|
+
title="{bar.label}: {format(bar.value)}"
|
|
34
|
+
>
|
|
35
|
+
<span class="label">{bar.label}</span>
|
|
36
|
+
<span class="track">
|
|
37
|
+
<!-- Width is the only inline style here: it is data, not design. -->
|
|
38
|
+
<span class="fill" style="width: {(bar.value / max) * 100}%"></span>
|
|
39
|
+
</span>
|
|
40
|
+
<span class="value">{format(bar.value)}</span>
|
|
41
|
+
</button>
|
|
42
|
+
</li>
|
|
43
|
+
{/each}
|
|
44
|
+
</ul>
|
|
45
|
+
{/if}
|
|
46
|
+
|
|
47
|
+
<style>
|
|
48
|
+
.bars { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
49
|
+
button {
|
|
50
|
+
display: grid; grid-template-columns: 8rem 1fr 6rem; align-items: center; gap: 0.75rem;
|
|
51
|
+
width: 100%; padding: 0.3rem 0.4rem; font: inherit; text-align: left;
|
|
52
|
+
background: none; border: 0; border-radius: var(--sg-radius, 8px); cursor: pointer;
|
|
53
|
+
color: var(--sg-fg, #0f172a);
|
|
54
|
+
}
|
|
55
|
+
button:hover { background: var(--sg-row-hover-bg, rgba(0, 0, 0, 0.04)); }
|
|
56
|
+
.row.is-active button { background: color-mix(in srgb, var(--sg-accent, #2563eb) 14%, transparent); }
|
|
57
|
+
.label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.875rem; }
|
|
58
|
+
.track { background: var(--sg-border, #e2e8f0); border-radius: 999px; height: 0.7rem; overflow: hidden; }
|
|
59
|
+
.fill { display: block; height: 100%; background: var(--sg-accent, #2563eb); border-radius: 999px; }
|
|
60
|
+
.value { font-variant-numeric: tabular-nums; font-size: 0.8125rem; text-align: right; color: var(--sg-muted, #64748b); }
|
|
61
|
+
.empty { color: var(--sg-muted, #64748b); font-size: 0.875rem; margin: 0; }
|
|
62
|
+
</style>
|
|
@@ -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:
|
|
@@ -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
|
+
}
|
|
@@ -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 })
|