@svgrid/enterprise 2.6.1 → 2.6.2
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/dist/ExpressionEditorHarness.svelte +47 -0
- package/dist/ExpressionEditorHarness.svelte.d.ts +15 -0
- package/dist/SvAdvancedFilter.svelte +208 -0
- package/dist/SvAdvancedFilter.svelte.d.ts +37 -0
- package/dist/SvExpressionEditor.svelte +251 -123
- package/dist/SvGridEditPanel.svelte +1157 -1157
- package/dist/advanced-filter/expr-columns-from-grid.d.ts +30 -0
- package/dist/advanced-filter/expr-columns-from-grid.js +44 -0
- package/dist/advanced-filter-enable.d.ts +5 -0
- package/dist/advanced-filter-enable.js +35 -0
- package/dist/cdn/svgrid-enterprise.svelte-external.js +8274 -7706
- package/dist/designer/assets/GridMenus-Bjr19Tz_.js +7 -0
- package/dist/designer/assets/{SvListBox-BVzVfCG3.js → SvListBox-BB8rt4aP.js} +1 -1
- package/dist/designer/assets/{index-CKdwDseq.js → index-xjfKckuH.js} +43 -43
- package/dist/designer/assets/{js-scroller.svelte-tHanKJx0.js → js-scroller.svelte-CGkUUXwV.js} +2 -2
- package/dist/designer/assets/src-BT3LjWJL.css +1 -0
- package/dist/designer/assets/src-DDhAK0WP.js +2893 -0
- package/dist/designer/index.html +5 -5
- package/dist/expressions/builder-tree.d.ts +62 -0
- package/dist/expressions/builder-tree.js +133 -0
- package/dist/expressions/compile.d.ts +14 -0
- package/dist/expressions/compile.js +286 -0
- package/dist/expressions/expression-columns.d.ts +1 -11
- package/dist/expressions/expression-columns.js +40 -11
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/install.js +2 -0
- package/dist/node/studio.js +61 -13
- package/dist/sources/rest.js +25 -0
- package/dist/sources/supabase.js +49 -3
- package/dist/studio/emit-project.js +2125 -2125
- package/dist/studio/ui-components.generated.js +33 -0
- package/dist/sveltekit/in-memory.js +96 -3
- package/dist/sveltekit/query-plan.d.ts +32 -1
- package/dist/sveltekit/query-plan.js +88 -1
- package/dist/sveltekit/sql.d.ts +20 -0
- package/dist/sveltekit/sql.js +26 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
- package/dist/designer/assets/GridMenus-CW-rnHS9.js +0 -7
- package/dist/designer/assets/src-DCKhP5Xy.css +0 -1
- package/dist/designer/assets/src-DLsSdh3J.js +0 -2893
|
@@ -159,15 +159,15 @@ function blockMarkup(entity, schemaVar, typeName, block, resolve, ctx = { hasRec
|
|
|
159
159
|
if (cfg.scheduler) {
|
|
160
160
|
const schDisp = relationDisplayFields(ctx.rawEntity ?? entity, resolve);
|
|
161
161
|
const schAsText = (f) => schDisp.get(f) ?? f;
|
|
162
|
-
return ` <div ${span}${cls}>
|
|
163
|
-
<SvGrid
|
|
164
|
-
data={allRows}
|
|
165
|
-
columns={${colVar}}
|
|
166
|
-
getRowId={(r) => String((r as Record<string, unknown>)[idField])}
|
|
167
|
-
loading={!allRowsReady}
|
|
168
|
-
scheduler={${schedulerConfigExpr(cfg.scheduler, typeName, schAsText)}}
|
|
169
|
-
containerHeight=${ctx.pane ? '"100%"' : `{${block.height ?? 640}}`}
|
|
170
|
-
/>
|
|
162
|
+
return ` <div ${span}${cls}>
|
|
163
|
+
<SvGrid
|
|
164
|
+
data={allRows}
|
|
165
|
+
columns={${colVar}}
|
|
166
|
+
getRowId={(r) => String((r as Record<string, unknown>)[idField])}
|
|
167
|
+
loading={!allRowsReady}
|
|
168
|
+
scheduler={${schedulerConfigExpr(cfg.scheduler, typeName, schAsText)}}
|
|
169
|
+
containerHeight=${ctx.pane ? '"100%"' : `{${block.height ?? 640}}`}
|
|
170
|
+
/>
|
|
171
171
|
</div>`;
|
|
172
172
|
}
|
|
173
173
|
// Grouped + tree grids both load the full dataset and render client-side (a grouped
|
|
@@ -311,22 +311,22 @@ function blockMarkup(entity, schemaVar, typeName, block, resolve, ctx = { hasRec
|
|
|
311
311
|
}
|
|
312
312
|
// No-code export toolbar - buttons wired to the grid's own export API.
|
|
313
313
|
const exportBar = gridHasExport(cfg) && ctx.captureApi ? exportToolbarMarkup(cfg.export, ctx.captureApi, entity.name) : '';
|
|
314
|
-
return ` <div ${span}${cls}>
|
|
315
|
-
${exportBar} <SvGrid
|
|
316
|
-
${lines.join('\n ')}
|
|
317
|
-
/>
|
|
314
|
+
return ` <div ${span}${cls}>
|
|
315
|
+
${exportBar} <SvGrid
|
|
316
|
+
${lines.join('\n ')}
|
|
317
|
+
/>
|
|
318
318
|
</div>`;
|
|
319
319
|
}
|
|
320
320
|
case 'chart': {
|
|
321
321
|
const drillRoute = cfg.drillScreen && ctx.routeById?.get(cfg.drillScreen);
|
|
322
322
|
const onDrill = drillRoute ? ` onDrill={(cat) => goto('/${drillRoute}?${cfg.dimension}=' + encodeURIComponent(String(cat)))}` : '';
|
|
323
|
-
return ` <div ${span}${cls}>
|
|
324
|
-
<SvSchemaChart schema={${schemaVar}} rows={${rowsExpr}} dimension="${cfg.dimension}"${cfg.measure ? ` measure="${cfg.measure}"` : ''} reduce="${cfg.reduce}" type="${cfg.type}"${block.height ? ` height={${block.height}}` : ''} controls={false} accent="${chartColorExpr(cfg.color)}"${cfg.dataLabels === false ? ' dataLabels={false}' : ''}${onDrill} />
|
|
323
|
+
return ` <div ${span}${cls}>
|
|
324
|
+
<SvSchemaChart schema={${schemaVar}} rows={${rowsExpr}} dimension="${cfg.dimension}"${cfg.measure ? ` measure="${cfg.measure}"` : ''} reduce="${cfg.reduce}" type="${cfg.type}"${block.height ? ` height={${block.height}}` : ''} controls={false} accent="${chartColorExpr(cfg.color)}"${cfg.dataLabels === false ? ' dataLabels={false}' : ''}${onDrill} />
|
|
325
325
|
</div>`;
|
|
326
326
|
}
|
|
327
327
|
case 'dashboard':
|
|
328
|
-
return ` <div ${span}${cls}>
|
|
329
|
-
<SvSchemaDashboard schema={${schemaVar}} rows={${rowsExpr}} />
|
|
328
|
+
return ` <div ${span}${cls}>
|
|
329
|
+
<SvSchemaDashboard schema={${schemaVar}} rows={${rowsExpr}} />
|
|
330
330
|
</div>`;
|
|
331
331
|
case 'kpi': {
|
|
332
332
|
const measurePart = cfg.measure ? `measure: '${cfg.measure}', ` : '';
|
|
@@ -354,9 +354,9 @@ ${exportBar} <SvGrid
|
|
|
354
354
|
const deltaChip = cfg.target == null
|
|
355
355
|
? `\n {@const _d = seriesDelta(_s)}\n {#if _d != null}<span class="kpi__delta" class:is-up={_d >= 0} class:is-down={_d < 0}>{_d >= 0 ? '▲' : '▼'} {Math.abs(_d).toFixed(0)}%</span>{/if}`
|
|
356
356
|
: '';
|
|
357
|
-
rows.push(` {#if ${seriesExpr}.length > 1}
|
|
358
|
-
{@const _s = ${seriesExpr}}${deltaChip}
|
|
359
|
-
<svg class="kpi__spark" viewBox="0 0 120 30" preserveAspectRatio="none" aria-hidden="true"><polyline points={sparklinePoints(_s)} fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke" /></svg>
|
|
357
|
+
rows.push(` {#if ${seriesExpr}.length > 1}
|
|
358
|
+
{@const _s = ${seriesExpr}}${deltaChip}
|
|
359
|
+
<svg class="kpi__spark" viewBox="0 0 120 30" preserveAspectRatio="none" aria-hidden="true"><polyline points={sparklinePoints(_s)} fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke" /></svg>
|
|
360
360
|
{/if}`);
|
|
361
361
|
}
|
|
362
362
|
return ` <div ${span}${wrapperClass(block, 'kpi')}>\n${rows.join('\n')}\n </div>`;
|
|
@@ -364,9 +364,9 @@ ${exportBar} <SvGrid
|
|
|
364
364
|
case 'gauge': {
|
|
365
365
|
const gexpr = `reduceValue(${rowsExpr}, { ${cfg.measure ? `measure: '${cfg.measure}', ` : ''}reduce: '${cfg.reduce}' })`;
|
|
366
366
|
const unit = cfg.unit ? ` unit=${JSON.stringify(cfg.unit)}` : '';
|
|
367
|
-
return ` <div ${span}${wrapperClass(block, 'gaugecard')}>
|
|
368
|
-
<span class="kpi__label">${tLabel(cfg.label, block.id)}</span>
|
|
369
|
-
<SvGauge value={${gexpr}} min={${cfg.min}} max={${cfg.max}}${unit} size={172} />
|
|
367
|
+
return ` <div ${span}${wrapperClass(block, 'gaugecard')}>
|
|
368
|
+
<span class="kpi__label">${tLabel(cfg.label, block.id)}</span>
|
|
369
|
+
<SvGauge value={${gexpr}} min={${cfg.min}} max={${cfg.max}}${unit} size={172} />
|
|
370
370
|
</div>`;
|
|
371
371
|
}
|
|
372
372
|
case 'tree': {
|
|
@@ -374,8 +374,8 @@ ${exportBar} <SvGrid
|
|
|
374
374
|
return ` <div ${span}${cls}><!-- tree: set a label field + a self-referential parent field in the inspector --></div>`;
|
|
375
375
|
}
|
|
376
376
|
const idExpr = `${schemaVar}.idField ?? 'id'`;
|
|
377
|
-
return ` <div ${span}${wrapperClass(block, 'treecard')}>
|
|
378
|
-
<SvTree nodes={toTreeNodes(${rowsExpr} as Record<string, unknown>[], ${idExpr}, ${JSON.stringify(cfg.labelField)}, ${JSON.stringify(cfg.parentField)})} />
|
|
377
|
+
return ` <div ${span}${wrapperClass(block, 'treecard')}>
|
|
378
|
+
<SvTree nodes={toTreeNodes(${rowsExpr} as Record<string, unknown>[], ${idExpr}, ${JSON.stringify(cfg.labelField)}, ${JSON.stringify(cfg.parentField)})} />
|
|
379
379
|
</div>`;
|
|
380
380
|
}
|
|
381
381
|
case 'tabs': {
|
|
@@ -388,19 +388,19 @@ ${exportBar} <SvGrid
|
|
|
388
388
|
const panels = cfg.tabs
|
|
389
389
|
.map((t, i) => {
|
|
390
390
|
const children = t.blocks.map((cb) => blockMarkup(entity, schemaVar, typeName, cb, resolve, ctx)).filter(Boolean).join('\n');
|
|
391
|
-
return ` {#if id === '${tabId(block.id, i)}'}
|
|
392
|
-
<div class="st-screen">
|
|
393
|
-
${children || ' <p style="color: var(--sg-muted, #94a3b8); font-size: 13px; padding: 10px;">This tab is empty.</p>'}
|
|
394
|
-
</div>
|
|
391
|
+
return ` {#if id === '${tabId(block.id, i)}'}
|
|
392
|
+
<div class="st-screen">
|
|
393
|
+
${children || ' <p style="color: var(--sg-muted, #94a3b8); font-size: 13px; padding: 10px;">This tab is empty.</p>'}
|
|
394
|
+
</div>
|
|
395
395
|
{/if}`;
|
|
396
396
|
})
|
|
397
397
|
.join('\n');
|
|
398
|
-
return ` <div ${span}${cls}>
|
|
399
|
-
<SvTabs tabs={${items}} value={${tabsVar}} onChange={(id) => (${tabsVar} = id)}>
|
|
400
|
-
{#snippet panel(id)}
|
|
401
|
-
${panels}
|
|
402
|
-
{/snippet}
|
|
403
|
-
</SvTabs>
|
|
398
|
+
return ` <div ${span}${cls}>
|
|
399
|
+
<SvTabs tabs={${items}} value={${tabsVar}} onChange={(id) => (${tabsVar} = id)}>
|
|
400
|
+
{#snippet panel(id)}
|
|
401
|
+
${panels}
|
|
402
|
+
{/snippet}
|
|
403
|
+
</SvTabs>
|
|
404
404
|
</div>`;
|
|
405
405
|
}
|
|
406
406
|
case 'accordion': {
|
|
@@ -411,19 +411,19 @@ ${panels}
|
|
|
411
411
|
const panels = cfg.sections
|
|
412
412
|
.map((s, i) => {
|
|
413
413
|
const children = s.blocks.map((cb) => blockMarkup(entity, schemaVar, typeName, cb, resolve, ctx)).filter(Boolean).join('\n');
|
|
414
|
-
return ` {#if item.id === '${accSectionId(block.id, i)}'}
|
|
415
|
-
<div class="st-screen">
|
|
416
|
-
${children || ' <p style="color: var(--sg-muted, #94a3b8); font-size: 13px; padding: 10px;">This section is empty.</p>'}
|
|
417
|
-
</div>
|
|
414
|
+
return ` {#if item.id === '${accSectionId(block.id, i)}'}
|
|
415
|
+
<div class="st-screen">
|
|
416
|
+
${children || ' <p style="color: var(--sg-muted, #94a3b8); font-size: 13px; padding: 10px;">This section is empty.</p>'}
|
|
417
|
+
</div>
|
|
418
418
|
{/if}`;
|
|
419
419
|
})
|
|
420
420
|
.join('\n');
|
|
421
|
-
return ` <div ${span}${cls}>
|
|
422
|
-
<SvAccordion items={${items}} expandMode="${cfg.multiple ? 'multiple' : 'single'}" expanded={${accVar}} onChange={(ids) => (${accVar} = ids)}>
|
|
423
|
-
{#snippet panel(item)}
|
|
424
|
-
${panels}
|
|
425
|
-
{/snippet}
|
|
426
|
-
</SvAccordion>
|
|
421
|
+
return ` <div ${span}${cls}>
|
|
422
|
+
<SvAccordion items={${items}} expandMode="${cfg.multiple ? 'multiple' : 'single'}" expanded={${accVar}} onChange={(ids) => (${accVar} = ids)}>
|
|
423
|
+
{#snippet panel(item)}
|
|
424
|
+
${panels}
|
|
425
|
+
{/snippet}
|
|
426
|
+
</SvAccordion>
|
|
427
427
|
</div>`;
|
|
428
428
|
}
|
|
429
429
|
case 'master-detail': {
|
|
@@ -437,14 +437,14 @@ ${panels}
|
|
|
437
437
|
// of expanding inline - the detail page shows the same children as a timeline.
|
|
438
438
|
const mdRoute = cfg.linkScreen ? ctx.routeById?.get(cfg.linkScreen) : undefined;
|
|
439
439
|
const onParent = mdRoute ? ` onParentClick={(id) => goto('/${mdRoute}?id=' + encodeURIComponent(id))}` : '';
|
|
440
|
-
return ` <div ${span}${cls}>
|
|
441
|
-
<SvGridMasterDetail schema={${schemaVar}} data={allRows} detailSchema={${cn.schemaVar}} getChildren={(p) => ${childRows}.filter((c) => String((c as Record<string, unknown>)['${cfg.foreignKey}']) === String((p as Record<string, unknown>)[${schemaVar}.idField ?? 'id']))} rowHeight={30}${onParent}${block.height ? ` containerHeight={${block.height}}` : ''} />
|
|
440
|
+
return ` <div ${span}${cls}>
|
|
441
|
+
<SvGridMasterDetail schema={${schemaVar}} data={allRows} detailSchema={${cn.schemaVar}} getChildren={(p) => ${childRows}.filter((c) => String((c as Record<string, unknown>)['${cfg.foreignKey}']) === String((p as Record<string, unknown>)[${schemaVar}.idField ?? 'id']))} rowHeight={30}${onParent}${block.height ? ` containerHeight={${block.height}}` : ''} />
|
|
442
442
|
</div>`;
|
|
443
443
|
}
|
|
444
444
|
case 'pivot': {
|
|
445
445
|
const h = block.height ?? 460;
|
|
446
|
-
return ` <div ${wrapperStyle(block, `height: ${h}px`)}${cls}>
|
|
447
|
-
<SvPivotDesigner data={${rowsExpr}} fields={${pivotFieldsExpr(entity)}} layout={${pivotLayoutExpr(cfg)}} />
|
|
446
|
+
return ` <div ${wrapperStyle(block, `height: ${h}px`)}${cls}>
|
|
447
|
+
<SvPivotDesigner data={${rowsExpr}} fields={${pivotFieldsExpr(entity)}} layout={${pivotLayoutExpr(cfg)}} />
|
|
448
448
|
</div>`;
|
|
449
449
|
}
|
|
450
450
|
case 'filter':
|
|
@@ -466,8 +466,8 @@ ${panels}
|
|
|
466
466
|
const openRoute = cfg.openScreen ? ctx.routeById?.get(cfg.openScreen) : undefined;
|
|
467
467
|
const onOpen = openRoute ? ` onOpen={(id) => goto('/${openRoute}?id=' + encodeURIComponent(id))}` : '';
|
|
468
468
|
// Dragging a card updates its groupBy value in the local row state (optimistic).
|
|
469
|
-
return ` <div ${wrapperStyle(block)}${cls}>
|
|
470
|
-
<SvBoard schema={${schemaVar}} rows={allRows} loading={!allRowsReady} groupBy=${JSON.stringify(cfg.groupBy)} titleField=${JSON.stringify(asText(cfg.titleField))}${badge}${sub}${onOpen} height={${h}} onMove={(id, value) => { allRows = allRows.map((r) => String((r as Record<string, unknown>)[idField]) === String(id) ? ({ ...r, ['${cfg.groupBy}']: value }) : r) }} />
|
|
469
|
+
return ` <div ${wrapperStyle(block)}${cls}>
|
|
470
|
+
<SvBoard schema={${schemaVar}} rows={allRows} loading={!allRowsReady} groupBy=${JSON.stringify(cfg.groupBy)} titleField=${JSON.stringify(asText(cfg.titleField))}${badge}${sub}${onOpen} height={${h}} onMove={(id, value) => { allRows = allRows.map((r) => String((r as Record<string, unknown>)[idField]) === String(id) ? ({ ...r, ['${cfg.groupBy}']: value }) : r) }} />
|
|
471
471
|
</div>`;
|
|
472
472
|
}
|
|
473
473
|
case 'calendar': {
|
|
@@ -481,8 +481,8 @@ ${panels}
|
|
|
481
481
|
// + recurrence): a richer view of the grid's rows with a detail drawer. `openScreen`
|
|
482
482
|
// navigation is superseded by the in-place drawer.
|
|
483
483
|
const sc = { startField: cfg.dateField, titleField: cfg.titleField, colorField: cfg.colorField, drawer: true };
|
|
484
|
-
return ` <div ${wrapperStyle(block)}${cls}>
|
|
485
|
-
<SvGrid data={allRows} columns={schemaToColumns(${schemaVar})} getRowId={(r) => String((r as Record<string, unknown>)[${schemaVar}.idField ?? 'id'])} loading={!allRowsReady} scheduler={${schedulerConfigExpr(sc, typeName, asText)}} containerHeight={${h}} />
|
|
484
|
+
return ` <div ${wrapperStyle(block)}${cls}>
|
|
485
|
+
<SvGrid data={allRows} columns={schemaToColumns(${schemaVar})} getRowId={(r) => String((r as Record<string, unknown>)[${schemaVar}.idField ?? 'id'])} loading={!allRowsReady} scheduler={${schedulerConfigExpr(sc, typeName, asText)}} containerHeight={${h}} />
|
|
486
486
|
</div>`;
|
|
487
487
|
}
|
|
488
488
|
case 'detail': {
|
|
@@ -530,8 +530,8 @@ ${panels}
|
|
|
530
530
|
props.push(`selectedId={page.url.searchParams.get('id') ?? undefined}`);
|
|
531
531
|
if (h)
|
|
532
532
|
props.push(`height={${h}}`);
|
|
533
|
-
return ` <div ${wrapperStyle(block)}${cls}>
|
|
534
|
-
<SvRecordDetail ${props.join(' ')} />
|
|
533
|
+
return ` <div ${wrapperStyle(block)}${cls}>
|
|
534
|
+
<SvRecordDetail ${props.join(' ')} />
|
|
535
535
|
</div>`;
|
|
536
536
|
}
|
|
537
537
|
case 'lookup':
|
|
@@ -565,12 +565,12 @@ function createFormMarkup(entity, schemaVar, block, cfg) {
|
|
|
565
565
|
// makes the new screen the confirmation.
|
|
566
566
|
const done = cfg.afterSave === 'navigate'
|
|
567
567
|
? ''
|
|
568
|
-
: `
|
|
568
|
+
: `
|
|
569
569
|
{#if formSaves}<p class="st-form__done" role="status">Saved. Add another below.</p>{/if}`;
|
|
570
|
-
return ` <div ${span}${cls}>${done}
|
|
571
|
-
{#key formSaves}
|
|
572
|
-
<SvGridEditPanel ${attrs.join(' ')} onSubmit={createRecord} />
|
|
573
|
-
{/key}
|
|
570
|
+
return ` <div ${span}${cls}>${done}
|
|
571
|
+
{#key formSaves}
|
|
572
|
+
<SvGridEditPanel ${attrs.join(' ')} onSubmit={createRecord} />
|
|
573
|
+
{/key}
|
|
574
574
|
</div>`;
|
|
575
575
|
}
|
|
576
576
|
/** Emits a UI-kit component block (see `UI_COMPONENT_REGISTRY`): a literal
|
|
@@ -614,9 +614,9 @@ function componentBlockMarkup(block, cfg, handleName, rowsExpr) {
|
|
|
614
614
|
const wrapper = STANDARD_UI_EVENTS.filter((se) => !propWired.some((e) => e.key === se.key))
|
|
615
615
|
.map((se) => ` on${se.dom}={(e) => ${handleName}.fire('${se.key}', e)}`)
|
|
616
616
|
.join('');
|
|
617
|
-
return ` <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
618
|
-
<div id="${block.id}"${wrapper} ${span}${cls}>
|
|
619
|
-
<${spec.importName} {...${handleName}.props}${eventAttrs}${inner}
|
|
617
|
+
return ` <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
618
|
+
<div id="${block.id}"${wrapper} ${span}${cls}>
|
|
619
|
+
<${spec.importName} {...${handleName}.props}${eventAttrs}${inner}
|
|
620
620
|
</div>`;
|
|
621
621
|
}
|
|
622
622
|
// Data bindings: a bound prop's value is a reactive expression over the screen's
|
|
@@ -652,8 +652,8 @@ function componentBlockMarkup(block, cfg, handleName, rowsExpr) {
|
|
|
652
652
|
: contentB
|
|
653
653
|
? `>{${bindingExpr(contentB, rowsExpr, false)}}</${spec.importName}>`
|
|
654
654
|
: `>{${jsStr(String(cfg.props._content ?? spec.contentDefault ?? ''))}}</${spec.importName}>`;
|
|
655
|
-
return ` <div id="${block.id}" ${span}${cls}>
|
|
656
|
-
${openTag}${inner}
|
|
655
|
+
return ` <div id="${block.id}" ${span}${cls}>
|
|
656
|
+
${openTag}${inner}
|
|
657
657
|
</div>`;
|
|
658
658
|
}
|
|
659
659
|
/** A JS expression for a component prop's configured value, by panel type. */
|
|
@@ -788,12 +788,12 @@ function filterPanelState(entity, block, cfg) {
|
|
|
788
788
|
return ` if (v[${key}]) c[${key}] = { operator: 'equals', value: v[${key}] }`;
|
|
789
789
|
return ` if (v[${key}]) c[${key}] = { operator: 'contains', value: v[${key}] }`;
|
|
790
790
|
}).join('\n');
|
|
791
|
-
return `let ${state} = $state<Record<string, string>>({})
|
|
792
|
-
function ${apply}() {
|
|
793
|
-
const v = ${state}
|
|
794
|
-
const c: Record<string, { operator: 'equals' | 'contains'; value: string }> = {}
|
|
795
|
-
${assigns}
|
|
796
|
-
controller.setFilter({ columns: c })
|
|
791
|
+
return `let ${state} = $state<Record<string, string>>({})
|
|
792
|
+
function ${apply}() {
|
|
793
|
+
const v = ${state}
|
|
794
|
+
const c: Record<string, { operator: 'equals' | 'contains'; value: string }> = {}
|
|
795
|
+
${assigns}
|
|
796
|
+
controller.setFilter({ columns: c })
|
|
797
797
|
}`;
|
|
798
798
|
}
|
|
799
799
|
/** The faceted filter sidebar markup, wired to its facet state. */
|
|
@@ -803,22 +803,22 @@ function filterPanelMarkup(entity, block, cfg) {
|
|
|
803
803
|
const set = `${state}[${jsStr(f.field)}] = e.currentTarget.value; ${apply}()`;
|
|
804
804
|
if (f.type === 'enum') {
|
|
805
805
|
const opts = enumOpts(f).map((o) => `<option value="${o.value}">${o.label}</option>`).join('');
|
|
806
|
-
return ` <label class="st-filter__row"><span>${fieldLabel(f)}</span>
|
|
807
|
-
<select onchange={(e) => { ${set} }}><option value="">Any</option>${opts}</select>
|
|
806
|
+
return ` <label class="st-filter__row"><span>${fieldLabel(f)}</span>
|
|
807
|
+
<select onchange={(e) => { ${set} }}><option value="">Any</option>${opts}</select>
|
|
808
808
|
</label>`;
|
|
809
809
|
}
|
|
810
810
|
if (f.type === 'boolean') {
|
|
811
|
-
return ` <label class="st-filter__row"><span>${fieldLabel(f)}</span>
|
|
812
|
-
<select onchange={(e) => { ${set} }}><option value="">Any</option><option value="true">Yes</option><option value="false">No</option></select>
|
|
811
|
+
return ` <label class="st-filter__row"><span>${fieldLabel(f)}</span>
|
|
812
|
+
<select onchange={(e) => { ${set} }}><option value="">Any</option><option value="true">Yes</option><option value="false">No</option></select>
|
|
813
813
|
</label>`;
|
|
814
814
|
}
|
|
815
|
-
return ` <label class="st-filter__row"><span>${fieldLabel(f)}</span>
|
|
816
|
-
<input type="search" placeholder="Search…" oninput={(e) => { ${set} }} />
|
|
815
|
+
return ` <label class="st-filter__row"><span>${fieldLabel(f)}</span>
|
|
816
|
+
<input type="search" placeholder="Search…" oninput={(e) => { ${set} }} />
|
|
817
817
|
</label>`;
|
|
818
818
|
}).join('\n');
|
|
819
|
-
return ` <aside ${wrapperStyle(block)}${wrapperClass(block, 'st-filter')}>
|
|
820
|
-
<div class="st-filter__title">${cfg.title ?? 'Filters'}</div>
|
|
821
|
-
${controls}
|
|
819
|
+
return ` <aside ${wrapperStyle(block)}${wrapperClass(block, 'st-filter')}>
|
|
820
|
+
<div class="st-filter__title">${cfg.title ?? 'Filters'}</div>
|
|
821
|
+
${controls}
|
|
822
822
|
</aside>`;
|
|
823
823
|
}
|
|
824
824
|
/** A `{#snippet}` rendering a grid row's action buttons (edit / delete / navigate).
|
|
@@ -851,10 +851,10 @@ function rowActionsSnippet(idSafe, typeName, entity, actions, routeById, gate, s
|
|
|
851
851
|
const target = a.targetField ?? idField;
|
|
852
852
|
return `<button type="button" class="st-rowaction" onclick={(e) => { e.stopPropagation(); goto('/${route}?${target}=' + encodeURIComponent(String((row as Record<string, unknown>)[${jsStr(src)}] ?? ''))) }}>${a.label ?? 'Open'}</button>`;
|
|
853
853
|
}).filter(Boolean).join('\n ');
|
|
854
|
-
return `{#snippet rowActions_${idSafe}({ row }: { row: ${typeName} })}
|
|
855
|
-
<div class="st-rowactions">
|
|
856
|
-
${buttons}
|
|
857
|
-
</div>
|
|
854
|
+
return `{#snippet rowActions_${idSafe}({ row }: { row: ${typeName} })}
|
|
855
|
+
<div class="st-rowactions">
|
|
856
|
+
${buttons}
|
|
857
|
+
</div>
|
|
858
858
|
{/snippet}`;
|
|
859
859
|
}
|
|
860
860
|
/** Sanitize a user action id into a valid JS identifier suffix. */
|
|
@@ -930,38 +930,38 @@ function filterSurfaceProps(cfg) {
|
|
|
930
930
|
function treeGridScript(idSafe, typeName, cfg) {
|
|
931
931
|
const parent = jsStr(cfg.treeData.parentField);
|
|
932
932
|
const rec = `(r as Record<string, unknown>)`;
|
|
933
|
-
return `let treeExpanded_${idSafe} = $state<Record<string, boolean>>({})
|
|
934
|
-
function toggleTree_${idSafe}(id: string) { treeExpanded_${idSafe} = { ...treeExpanded_${idSafe}, [id]: !(treeExpanded_${idSafe}[id] ?? true) } }
|
|
935
|
-
function treeBuild_${idSafe}() {
|
|
936
|
-
const rows = allRows
|
|
937
|
-
const idOf = (r: ${typeName}) => String(${rec}[idField] ?? '')
|
|
938
|
-
const parentOf = (r: ${typeName}) => { const v = ${rec}[${parent}]; return v == null || v === '' ? null : String(v) }
|
|
939
|
-
const present = new Set(rows.map(idOf))
|
|
940
|
-
const children = new Map<string | null, ${typeName}[]>()
|
|
941
|
-
for (const r of rows) { const p = parentOf(r); const key = p != null && present.has(p) ? p : null; const list = children.get(key) ?? []; list.push(r); children.set(key, list) }
|
|
942
|
-
const info = new Map<string, { depth: number; hasChildren: boolean; expanded: boolean }>()
|
|
943
|
-
const visible: ${typeName}[] = []
|
|
944
|
-
const walk = (parentId: string | null, depth: number) => {
|
|
945
|
-
for (const r of children.get(parentId) ?? []) {
|
|
946
|
-
const id = idOf(r); const hasChildren = (children.get(id)?.length ?? 0) > 0; const expanded = treeExpanded_${idSafe}[id] ?? true
|
|
947
|
-
info.set(id, { depth, hasChildren, expanded }); visible.push(r)
|
|
948
|
-
if (hasChildren && expanded) walk(id, depth + 1)
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
walk(null, 0)
|
|
952
|
-
return { info, visible }
|
|
953
|
-
}
|
|
933
|
+
return `let treeExpanded_${idSafe} = $state<Record<string, boolean>>({})
|
|
934
|
+
function toggleTree_${idSafe}(id: string) { treeExpanded_${idSafe} = { ...treeExpanded_${idSafe}, [id]: !(treeExpanded_${idSafe}[id] ?? true) } }
|
|
935
|
+
function treeBuild_${idSafe}() {
|
|
936
|
+
const rows = allRows
|
|
937
|
+
const idOf = (r: ${typeName}) => String(${rec}[idField] ?? '')
|
|
938
|
+
const parentOf = (r: ${typeName}) => { const v = ${rec}[${parent}]; return v == null || v === '' ? null : String(v) }
|
|
939
|
+
const present = new Set(rows.map(idOf))
|
|
940
|
+
const children = new Map<string | null, ${typeName}[]>()
|
|
941
|
+
for (const r of rows) { const p = parentOf(r); const key = p != null && present.has(p) ? p : null; const list = children.get(key) ?? []; list.push(r); children.set(key, list) }
|
|
942
|
+
const info = new Map<string, { depth: number; hasChildren: boolean; expanded: boolean }>()
|
|
943
|
+
const visible: ${typeName}[] = []
|
|
944
|
+
const walk = (parentId: string | null, depth: number) => {
|
|
945
|
+
for (const r of children.get(parentId) ?? []) {
|
|
946
|
+
const id = idOf(r); const hasChildren = (children.get(id)?.length ?? 0) > 0; const expanded = treeExpanded_${idSafe}[id] ?? true
|
|
947
|
+
info.set(id, { depth, hasChildren, expanded }); visible.push(r)
|
|
948
|
+
if (hasChildren && expanded) walk(id, depth + 1)
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
walk(null, 0)
|
|
952
|
+
return { info, visible }
|
|
953
|
+
}
|
|
954
954
|
const tree_${idSafe} = $derived(treeBuild_${idSafe}())`;
|
|
955
955
|
}
|
|
956
956
|
/** The `{#snippet}` for a tree grid's label column: indent by depth + an expand toggle. */
|
|
957
957
|
function treeCellSnippet(idSafe, typeName) {
|
|
958
958
|
const idExpr = `String((row as Record<string, unknown>)[idField] ?? '')`;
|
|
959
|
-
return `{#snippet treeCell_${idSafe}({ value, row }: { value: unknown; row: ${typeName} })}
|
|
960
|
-
{@const info = tree_${idSafe}.info.get(${idExpr})}
|
|
961
|
-
<span class="st-treecell" style="padding-left: {(info?.depth ?? 0) * 18}px;">
|
|
962
|
-
{#if info?.hasChildren}<button type="button" class="st-tree-toggle" aria-label="Toggle" aria-expanded={info.expanded} onclick={() => toggleTree_${idSafe}(${idExpr})}>{info.expanded ? '▾' : '▸'}</button>{:else}<span class="st-tree-spacer"></span>{/if}
|
|
963
|
-
<span>{String(value ?? '')}</span>
|
|
964
|
-
</span>
|
|
959
|
+
return `{#snippet treeCell_${idSafe}({ value, row }: { value: unknown; row: ${typeName} })}
|
|
960
|
+
{@const info = tree_${idSafe}.info.get(${idExpr})}
|
|
961
|
+
<span class="st-treecell" style="padding-left: {(info?.depth ?? 0) * 18}px;">
|
|
962
|
+
{#if info?.hasChildren}<button type="button" class="st-tree-toggle" aria-label="Toggle" aria-expanded={info.expanded} onclick={() => toggleTree_${idSafe}(${idExpr})}>{info.expanded ? '▾' : '▸'}</button>{:else}<span class="st-tree-spacer"></span>{/if}
|
|
963
|
+
<span>{String(value ?? '')}</span>
|
|
964
|
+
</span>
|
|
965
965
|
{/snippet}`;
|
|
966
966
|
}
|
|
967
967
|
/** The export toolbar markup for a grid: buttons wired to the captured grid API var. */
|
|
@@ -987,36 +987,36 @@ function exportToolbarMarkup(e, apiVar, entityName) {
|
|
|
987
987
|
}
|
|
988
988
|
/** Shared helper: map a status-ish value to a badge intent by common vocabulary.
|
|
989
989
|
* Emitted once per page when any column uses the `badge` cell renderer. */
|
|
990
|
-
const BADGE_VARIANT_HELPER = ` function stBadgeVariant(value: unknown): 'neutral' | 'success' | 'warning' | 'danger' | 'info' {
|
|
991
|
-
const v = String(value ?? '').toLowerCase().trim()
|
|
992
|
-
if (['active', 'open', 'success', 'done', 'paid', 'approved', 'complete', 'completed', 'won', 'shipped', 'yes', 'true'].includes(v)) return 'success'
|
|
993
|
-
if (['pending', 'warning', 'in progress', 'processing', 'trial', 'review', 'on hold', 'medium'].includes(v)) return 'warning'
|
|
994
|
-
if (['closed', 'error', 'failed', 'cancelled', 'canceled', 'overdue', 'rejected', 'lost', 'blocked', 'inactive', 'no', 'false', 'high', 'urgent'].includes(v)) return 'danger'
|
|
995
|
-
if (['new', 'info', 'draft', 'low'].includes(v)) return 'info'
|
|
996
|
-
return 'neutral'
|
|
990
|
+
const BADGE_VARIANT_HELPER = ` function stBadgeVariant(value: unknown): 'neutral' | 'success' | 'warning' | 'danger' | 'info' {
|
|
991
|
+
const v = String(value ?? '').toLowerCase().trim()
|
|
992
|
+
if (['active', 'open', 'success', 'done', 'paid', 'approved', 'complete', 'completed', 'won', 'shipped', 'yes', 'true'].includes(v)) return 'success'
|
|
993
|
+
if (['pending', 'warning', 'in progress', 'processing', 'trial', 'review', 'on hold', 'medium'].includes(v)) return 'warning'
|
|
994
|
+
if (['closed', 'error', 'failed', 'cancelled', 'canceled', 'overdue', 'rejected', 'lost', 'blocked', 'inactive', 'no', 'false', 'high', 'urgent'].includes(v)) return 'danger'
|
|
995
|
+
if (['new', 'info', 'draft', 'low'].includes(v)) return 'info'
|
|
996
|
+
return 'neutral'
|
|
997
997
|
}`;
|
|
998
998
|
/** Shared helper: run an `@svgrid/enterprise` export off a captured grid API.
|
|
999
999
|
* Emitted once per page when any grid has an xlsx / pdf button. Surfaces the
|
|
1000
1000
|
* failure instead of swallowing it - a missing optional peer dep (jszip for
|
|
1001
1001
|
* xlsx, pdfmake for pdf) is the usual cause and is worth seeing. */
|
|
1002
|
-
const EXPORT_HELPER = ` async function stExport(api: SvGridApi<never, never> | undefined, format: 'xlsx' | 'pdf', filename: string) {
|
|
1003
|
-
if (!api) return
|
|
1004
|
-
try {
|
|
1005
|
-
await exportGrid(api, { format, filename })
|
|
1006
|
-
} catch (err) {
|
|
1007
|
-
console.error('Export failed:', err)
|
|
1008
|
-
alert('Export failed: ' + (err instanceof Error ? err.message : String(err)))
|
|
1009
|
-
}
|
|
1002
|
+
const EXPORT_HELPER = ` async function stExport(api: SvGridApi<never, never> | undefined, format: 'xlsx' | 'pdf', filename: string) {
|
|
1003
|
+
if (!api) return
|
|
1004
|
+
try {
|
|
1005
|
+
await exportGrid(api, { format, filename })
|
|
1006
|
+
} catch (err) {
|
|
1007
|
+
console.error('Export failed:', err)
|
|
1008
|
+
alert('Export failed: ' + (err instanceof Error ? err.message : String(err)))
|
|
1009
|
+
}
|
|
1010
1010
|
}`;
|
|
1011
1011
|
/** Shared helper: paginated print off a captured grid API. */
|
|
1012
|
-
const PRINT_HELPER = ` async function stPrint(api: SvGridApi<never, never> | undefined, title: string) {
|
|
1013
|
-
if (!api) return
|
|
1014
|
-
try {
|
|
1015
|
-
await printGrid(api, { title })
|
|
1016
|
-
} catch (err) {
|
|
1017
|
-
console.error('Print failed:', err)
|
|
1018
|
-
alert('Print failed: ' + (err instanceof Error ? err.message : String(err)))
|
|
1019
|
-
}
|
|
1012
|
+
const PRINT_HELPER = ` async function stPrint(api: SvGridApi<never, never> | undefined, title: string) {
|
|
1013
|
+
if (!api) return
|
|
1014
|
+
try {
|
|
1015
|
+
await printGrid(api, { title })
|
|
1016
|
+
} catch (err) {
|
|
1017
|
+
console.error('Print failed:', err)
|
|
1018
|
+
alert('Print failed: ' + (err instanceof Error ? err.message : String(err)))
|
|
1019
|
+
}
|
|
1020
1020
|
}`;
|
|
1021
1021
|
/** The `{#snippet}` body for one rich cell renderer (badge / progress / link). */
|
|
1022
1022
|
function cellRendererSnippet(idSafe, field, cellType) {
|
|
@@ -1039,17 +1039,17 @@ function cellRendererSnippet(idSafe, field, cellType) {
|
|
|
1039
1039
|
* assumed - swap for the app's own if it has one). */
|
|
1040
1040
|
function actionHandlerScript(a) {
|
|
1041
1041
|
const safe = actionIdSafe(a.id);
|
|
1042
|
-
return `let actionBusy_${safe} = $state(false)
|
|
1043
|
-
async function runAction_${safe}(payload?: Record<string, unknown>) {
|
|
1044
|
-
actionBusy_${safe} = true
|
|
1045
|
-
try {
|
|
1046
|
-
const res = await fetch('/api/actions/${a.id}', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload ?? {}) })
|
|
1047
|
-
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as { error?: string }).error ?? 'Action failed')
|
|
1048
|
-
} catch (err) {
|
|
1049
|
-
alert(err instanceof Error ? err.message : 'Action failed')
|
|
1050
|
-
} finally {
|
|
1051
|
-
actionBusy_${safe} = false
|
|
1052
|
-
}
|
|
1042
|
+
return `let actionBusy_${safe} = $state(false)
|
|
1043
|
+
async function runAction_${safe}(payload?: Record<string, unknown>) {
|
|
1044
|
+
actionBusy_${safe} = true
|
|
1045
|
+
try {
|
|
1046
|
+
const res = await fetch('/api/actions/${a.id}', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload ?? {}) })
|
|
1047
|
+
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as { error?: string }).error ?? 'Action failed')
|
|
1048
|
+
} catch (err) {
|
|
1049
|
+
alert(err instanceof Error ? err.message : 'Action failed')
|
|
1050
|
+
} finally {
|
|
1051
|
+
actionBusy_${safe} = false
|
|
1052
|
+
}
|
|
1053
1053
|
}`;
|
|
1054
1054
|
}
|
|
1055
1055
|
/** A toolbar action button - RBAC-gated by screen access (not a CRUD `can()`
|
|
@@ -1071,15 +1071,15 @@ function actionRouteFile(a, screenId, accessEnabled) {
|
|
|
1071
1071
|
return {
|
|
1072
1072
|
path: `src/routes/api/actions/${a.id}/+server.ts`,
|
|
1073
1073
|
description: `Stub route for the "${a.label}" action - fill in the actual logic.`,
|
|
1074
|
-
contents: `${accessImport}
|
|
1075
|
-
export async function POST(event: { request: Request; locals?: Record<string, unknown> }) {${guard}
|
|
1076
|
-
const body = await event.request.json().catch(() => ({})) as Record<string, unknown>
|
|
1077
|
-
void body // the row id (row actions) or {} (toolbar actions) - use it to look up what to act on
|
|
1078
|
-
|
|
1079
|
-
// TODO: your business logic here.
|
|
1080
|
-
|
|
1081
|
-
return new Response(JSON.stringify({ ok: true }))
|
|
1082
|
-
}
|
|
1074
|
+
contents: `${accessImport}
|
|
1075
|
+
export async function POST(event: { request: Request; locals?: Record<string, unknown> }) {${guard}
|
|
1076
|
+
const body = await event.request.json().catch(() => ({})) as Record<string, unknown>
|
|
1077
|
+
void body // the row id (row actions) or {} (toolbar actions) - use it to look up what to act on
|
|
1078
|
+
|
|
1079
|
+
// TODO: your business logic here.
|
|
1080
|
+
|
|
1081
|
+
return new Response(JSON.stringify({ ok: true }))
|
|
1082
|
+
}
|
|
1083
1083
|
`,
|
|
1084
1084
|
};
|
|
1085
1085
|
}
|
|
@@ -1112,14 +1112,14 @@ function recordPanelMarkup(entity, schemaVar, block, cfg) {
|
|
|
1112
1112
|
// Modal / drawer float over the page (shown only while a row is selected);
|
|
1113
1113
|
// inline lives in the block, with a prompt when nothing is selected.
|
|
1114
1114
|
inner = pres === 'inline'
|
|
1115
|
-
? ` {#if selectedRecord}
|
|
1116
|
-
<SvGridEditPanel schema={${schemaVar}} row={selectedRecord} presentation="inline" onSubmit={saveRecord} onCancel={() => (selectedRecord = null)} />
|
|
1117
|
-
{:else}
|
|
1118
|
-
<p class="st-hint">Select a row to see its details.</p>
|
|
1115
|
+
? ` {#if selectedRecord}
|
|
1116
|
+
<SvGridEditPanel schema={${schemaVar}} row={selectedRecord} presentation="inline" onSubmit={saveRecord} onCancel={() => (selectedRecord = null)} />
|
|
1117
|
+
{:else}
|
|
1118
|
+
<p class="st-hint">Select a row to see its details.</p>
|
|
1119
1119
|
{/if}`
|
|
1120
|
-
: ` <p class="st-hint">Select a row to open its ${pres === 'drawer' ? 'editor drawer' : 'edit dialog'}.</p>
|
|
1121
|
-
{#if selectedRecord}
|
|
1122
|
-
<SvGridEditPanel schema={${schemaVar}} row={selectedRecord} presentation="${pres}" onSubmit={saveRecord} onCancel={() => (selectedRecord = null)} />
|
|
1120
|
+
: ` <p class="st-hint">Select a row to open its ${pres === 'drawer' ? 'editor drawer' : 'edit dialog'}.</p>
|
|
1121
|
+
{#if selectedRecord}
|
|
1122
|
+
<SvGridEditPanel schema={${schemaVar}} row={selectedRecord} presentation="${pres}" onSubmit={saveRecord} onCancel={() => (selectedRecord = null)} />
|
|
1123
1123
|
{/if}`;
|
|
1124
1124
|
}
|
|
1125
1125
|
else {
|
|
@@ -1128,16 +1128,16 @@ function recordPanelMarkup(entity, schemaVar, block, cfg) {
|
|
|
1128
1128
|
? entity.fields.filter((f) => cfg.fields.includes(f.field))
|
|
1129
1129
|
: entity.fields.filter((f) => f.field !== pk);
|
|
1130
1130
|
const rows = chosen.map((f) => ` <div class="st-record__row"><dt>${fieldLabel(f)}</dt><dd>{String((selectedRecord as Record<string, unknown>)[${jsStr(f.field)}] ?? '')}</dd></div>`).join('\n');
|
|
1131
|
-
inner = ` {#if selectedRecord}
|
|
1132
|
-
<dl class="st-record">
|
|
1133
|
-
${rows}
|
|
1134
|
-
</dl>
|
|
1135
|
-
{:else}
|
|
1136
|
-
<p class="st-hint">Select a row to see its details.</p>
|
|
1131
|
+
inner = ` {#if selectedRecord}
|
|
1132
|
+
<dl class="st-record">
|
|
1133
|
+
${rows}
|
|
1134
|
+
</dl>
|
|
1135
|
+
{:else}
|
|
1136
|
+
<p class="st-hint">Select a row to see its details.</p>
|
|
1137
1137
|
{/if}`;
|
|
1138
1138
|
}
|
|
1139
|
-
return ` <div ${span}${wrapperClass(block, 'st-record-card')}>
|
|
1140
|
-
${inner}
|
|
1139
|
+
return ` <div ${span}${wrapperClass(block, 'st-record-card')}>
|
|
1140
|
+
${inner}
|
|
1141
1141
|
</div>`;
|
|
1142
1142
|
}
|
|
1143
1143
|
/** A freestanding page - no bound entity, so no entity-bound `Block`; it can
|
|
@@ -1304,56 +1304,56 @@ export function ctxCompletions(screen) {
|
|
|
1304
1304
|
* (so calls never false-error) while keeping useful returns (Promise<string>,
|
|
1305
1305
|
* boolean, ...). Self-contained: the only external reference is the `Row` param.
|
|
1306
1306
|
* Powers the in-editor TypeScript service's hover / signature / diagnostics. */
|
|
1307
|
-
const GRID_API_SIGNATURES = ` getCellValue(rowIndex: number, columnId: string): unknown
|
|
1308
|
-
setCellValue(rowIndex: number, columnId: string, value: unknown): void
|
|
1309
|
-
startEditing(rowIndex: number, columnId: string): boolean
|
|
1310
|
-
stopEditing(cancel?: boolean): boolean
|
|
1311
|
-
selectCells(ranges: ReadonlyArray<readonly [number, number, number, number]>): void
|
|
1312
|
-
getSelected(): Array<[number, number, number, number]>
|
|
1313
|
-
openChart(): void
|
|
1314
|
-
closeChart(): void
|
|
1315
|
-
getChartSpec(): unknown
|
|
1316
|
-
chartRange(ranges?: ReadonlyArray<readonly [number, number, number, number]>): void
|
|
1317
|
-
configureChart(config: { open?: boolean; type?: string; dimension?: string | null; series?: string | null; measure?: string | null; reduce?: 'sum' | 'avg' | 'count'; stacked?: boolean; dataLabels?: boolean; logScale?: boolean; timeAxis?: boolean; valueFormat?: 'number' | 'currency' | 'percent' | 'compact' }): void
|
|
1318
|
-
setChartAiHandler(handler: ((prompt: string) => Promise<Record<string, unknown> | null>) | null): void
|
|
1319
|
-
addRow(row: Row, position?: 'top' | 'bottom' | number): void
|
|
1320
|
-
addRows(rows: ReadonlyArray<Row>, position?: 'top' | 'bottom' | number): void
|
|
1321
|
-
removeRow(rowIndex: number): void
|
|
1322
|
-
removeRows(rowIndices: ReadonlyArray<number>): void
|
|
1323
|
-
applyTransaction(tx: { add?: Row[]; update?: Row[]; remove?: Array<string | Row> }): { added: number; updated: number; removed: number }
|
|
1324
|
-
addColumn(column: any, position?: 'left' | 'right' | number): void
|
|
1325
|
-
addColumns(columns: ReadonlyArray<any>, position?: 'left' | 'right' | number): void
|
|
1326
|
-
removeColumn(columnId: string): void
|
|
1327
|
-
setColumnVisible(columnId: string, visible: boolean): void
|
|
1328
|
-
isColumnVisible(columnId: string): boolean
|
|
1329
|
-
setSort(columnId: string, direction: 'asc' | 'desc' | null): void
|
|
1330
|
-
clearSort(): void
|
|
1331
|
-
setGroupBy(columnIds: ReadonlyArray<string>): void
|
|
1332
|
-
setFilter(columnId: string, filter: any | null): void
|
|
1333
|
-
setFacetFilter(columnId: string, values: ReadonlyArray<string> | null): void
|
|
1334
|
-
clearFilter(columnId: string): void
|
|
1335
|
-
clearAllFilters(): void
|
|
1336
|
-
getFilters(): Record<string, { operator: string; value: string; valueTo?: string }>
|
|
1337
|
-
getDisplayedRows(): ReadonlyArray<Row>
|
|
1338
|
-
getData(): ReadonlyArray<Row>
|
|
1339
|
-
getColumns(): ReadonlyArray<{ id: string; header: string; visible: boolean }>
|
|
1340
|
-
exportCsv(options?: any): Promise<string>
|
|
1341
|
-
exportTsv(options?: any): Promise<string>
|
|
1342
|
-
exportJson(options?: any): Promise<string>
|
|
1343
|
-
copyToClipboard(options?: any): Promise<string>
|
|
1344
|
-
clearRowSelection(): void
|
|
1345
|
-
setColumnWidth(columnId: string, width: number): void
|
|
1346
|
-
getColumnWidths(): Record<string, number>
|
|
1347
|
-
autosizeColumn(columnId: string): void
|
|
1348
|
-
autosizeAllColumns(): void
|
|
1349
|
-
setColumnPinning(pinning: { left?: string[]; right?: string[] }): void
|
|
1350
|
-
getColumnPinning(): { left: string[]; right: string[] }
|
|
1351
|
-
setColumnOrder(order: ReadonlyArray<string>): void
|
|
1352
|
-
getColumnOrder(): string[]
|
|
1353
|
-
setRowExpanded(id: string, expanded: boolean): void
|
|
1354
|
-
expandAllGroups(): void
|
|
1355
|
-
collapseAllGroups(): void
|
|
1356
|
-
undo(): boolean
|
|
1307
|
+
const GRID_API_SIGNATURES = ` getCellValue(rowIndex: number, columnId: string): unknown
|
|
1308
|
+
setCellValue(rowIndex: number, columnId: string, value: unknown): void
|
|
1309
|
+
startEditing(rowIndex: number, columnId: string): boolean
|
|
1310
|
+
stopEditing(cancel?: boolean): boolean
|
|
1311
|
+
selectCells(ranges: ReadonlyArray<readonly [number, number, number, number]>): void
|
|
1312
|
+
getSelected(): Array<[number, number, number, number]>
|
|
1313
|
+
openChart(): void
|
|
1314
|
+
closeChart(): void
|
|
1315
|
+
getChartSpec(): unknown
|
|
1316
|
+
chartRange(ranges?: ReadonlyArray<readonly [number, number, number, number]>): void
|
|
1317
|
+
configureChart(config: { open?: boolean; type?: string; dimension?: string | null; series?: string | null; measure?: string | null; reduce?: 'sum' | 'avg' | 'count'; stacked?: boolean; dataLabels?: boolean; logScale?: boolean; timeAxis?: boolean; valueFormat?: 'number' | 'currency' | 'percent' | 'compact' }): void
|
|
1318
|
+
setChartAiHandler(handler: ((prompt: string) => Promise<Record<string, unknown> | null>) | null): void
|
|
1319
|
+
addRow(row: Row, position?: 'top' | 'bottom' | number): void
|
|
1320
|
+
addRows(rows: ReadonlyArray<Row>, position?: 'top' | 'bottom' | number): void
|
|
1321
|
+
removeRow(rowIndex: number): void
|
|
1322
|
+
removeRows(rowIndices: ReadonlyArray<number>): void
|
|
1323
|
+
applyTransaction(tx: { add?: Row[]; update?: Row[]; remove?: Array<string | Row> }): { added: number; updated: number; removed: number }
|
|
1324
|
+
addColumn(column: any, position?: 'left' | 'right' | number): void
|
|
1325
|
+
addColumns(columns: ReadonlyArray<any>, position?: 'left' | 'right' | number): void
|
|
1326
|
+
removeColumn(columnId: string): void
|
|
1327
|
+
setColumnVisible(columnId: string, visible: boolean): void
|
|
1328
|
+
isColumnVisible(columnId: string): boolean
|
|
1329
|
+
setSort(columnId: string, direction: 'asc' | 'desc' | null): void
|
|
1330
|
+
clearSort(): void
|
|
1331
|
+
setGroupBy(columnIds: ReadonlyArray<string>): void
|
|
1332
|
+
setFilter(columnId: string, filter: any | null): void
|
|
1333
|
+
setFacetFilter(columnId: string, values: ReadonlyArray<string> | null): void
|
|
1334
|
+
clearFilter(columnId: string): void
|
|
1335
|
+
clearAllFilters(): void
|
|
1336
|
+
getFilters(): Record<string, { operator: string; value: string; valueTo?: string }>
|
|
1337
|
+
getDisplayedRows(): ReadonlyArray<Row>
|
|
1338
|
+
getData(): ReadonlyArray<Row>
|
|
1339
|
+
getColumns(): ReadonlyArray<{ id: string; header: string; visible: boolean }>
|
|
1340
|
+
exportCsv(options?: any): Promise<string>
|
|
1341
|
+
exportTsv(options?: any): Promise<string>
|
|
1342
|
+
exportJson(options?: any): Promise<string>
|
|
1343
|
+
copyToClipboard(options?: any): Promise<string>
|
|
1344
|
+
clearRowSelection(): void
|
|
1345
|
+
setColumnWidth(columnId: string, width: number): void
|
|
1346
|
+
getColumnWidths(): Record<string, number>
|
|
1347
|
+
autosizeColumn(columnId: string): void
|
|
1348
|
+
autosizeAllColumns(): void
|
|
1349
|
+
setColumnPinning(pinning: { left?: string[]; right?: string[] }): void
|
|
1350
|
+
getColumnPinning(): { left: string[]; right: string[] }
|
|
1351
|
+
setColumnOrder(order: ReadonlyArray<string>): void
|
|
1352
|
+
getColumnOrder(): string[]
|
|
1353
|
+
setRowExpanded(id: string, expanded: boolean): void
|
|
1354
|
+
expandAllGroups(): void
|
|
1355
|
+
collapseAllGroups(): void
|
|
1356
|
+
undo(): boolean
|
|
1357
1357
|
redo(): boolean`;
|
|
1358
1358
|
/** Map an entity field to a TS type for the generated Row interface (mirrors scaffold.ts). */
|
|
1359
1359
|
function fieldTsType(f) {
|
|
@@ -1390,35 +1390,35 @@ export function ctxAmbientDts(screen, entity) {
|
|
|
1390
1390
|
members.push(` data: { rows: ${rowName}[]; reload(): void }`);
|
|
1391
1391
|
members.push(' goto(path: string): void');
|
|
1392
1392
|
members.push(' params: Record<string, string>');
|
|
1393
|
-
return `// Ambient types for the "${screen.title}" screen's code-behind. Regenerated - editor use only.
|
|
1394
|
-
${rowDecl}
|
|
1395
|
-
|
|
1396
|
-
interface SvGridApi<Row> {
|
|
1397
|
-
${GRID_API_SIGNATURES}
|
|
1398
|
-
}
|
|
1399
|
-
|
|
1400
|
-
interface DataHandle<T> {
|
|
1401
|
-
/** Feed this block its own rows (overrides the screen dataset until cleared). */
|
|
1402
|
-
setData(rows: T[]): void
|
|
1403
|
-
/** The rows the block renders: the override if set, else the screen dataset. */
|
|
1404
|
-
readonly rows: T[]
|
|
1405
|
-
/** Drop the override; the block follows the screen dataset again. */
|
|
1406
|
-
clear(): void
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
interface Handle {
|
|
1410
|
-
setText(value: string): void
|
|
1411
|
-
setLabel(value: string): void
|
|
1412
|
-
set(name: string, value: unknown): void
|
|
1413
|
-
get(name: string): unknown
|
|
1414
|
-
onClick(fn: (e: Event) => void): void
|
|
1415
|
-
onclick: (e: Event) => void
|
|
1416
|
-
[key: string]: any
|
|
1417
|
-
}
|
|
1418
|
-
${componentTypeDecls ? '\n' + componentTypeDecls + '\n' : ''}
|
|
1419
|
-
interface PageContext {
|
|
1420
|
-
${members.join('\n')}
|
|
1421
|
-
}
|
|
1393
|
+
return `// Ambient types for the "${screen.title}" screen's code-behind. Regenerated - editor use only.
|
|
1394
|
+
${rowDecl}
|
|
1395
|
+
|
|
1396
|
+
interface SvGridApi<Row> {
|
|
1397
|
+
${GRID_API_SIGNATURES}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
interface DataHandle<T> {
|
|
1401
|
+
/** Feed this block its own rows (overrides the screen dataset until cleared). */
|
|
1402
|
+
setData(rows: T[]): void
|
|
1403
|
+
/** The rows the block renders: the override if set, else the screen dataset. */
|
|
1404
|
+
readonly rows: T[]
|
|
1405
|
+
/** Drop the override; the block follows the screen dataset again. */
|
|
1406
|
+
clear(): void
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
interface Handle {
|
|
1410
|
+
setText(value: string): void
|
|
1411
|
+
setLabel(value: string): void
|
|
1412
|
+
set(name: string, value: unknown): void
|
|
1413
|
+
get(name: string): unknown
|
|
1414
|
+
onClick(fn: (e: Event) => void): void
|
|
1415
|
+
onclick: (e: Event) => void
|
|
1416
|
+
[key: string]: any
|
|
1417
|
+
}
|
|
1418
|
+
${componentTypeDecls ? '\n' + componentTypeDecls + '\n' : ''}
|
|
1419
|
+
interface PageContext {
|
|
1420
|
+
${members.join('\n')}
|
|
1421
|
+
}
|
|
1422
1422
|
`;
|
|
1423
1423
|
}
|
|
1424
1424
|
/** A comment manifest of what `ctx` gives onLoad/onDestroy - each handle plus the
|
|
@@ -1451,103 +1451,103 @@ function handlesModuleFile() {
|
|
|
1451
1451
|
return {
|
|
1452
1452
|
path: 'src/lib/handles.svelte.ts',
|
|
1453
1453
|
description: 'Reactive imperative handles for UI components (btn.setLabel, btn.onclick = ...).',
|
|
1454
|
-
contents: `// Regenerated by SvGrid Studio.
|
|
1455
|
-
/** An imperative, reactive handle over a UI component. In page code you get one
|
|
1456
|
-
* per component (e.g. \`button1\`); mutate it and the component updates. */
|
|
1457
|
-
export class ComponentHandle {
|
|
1458
|
-
props = $state<Record<string, unknown>>({})
|
|
1459
|
-
text = $state('')
|
|
1460
|
-
on = $state<Record<string, (e: Event) => void>>({})
|
|
1461
|
-
constructor(init: { props?: Record<string, unknown>; text?: string }) {
|
|
1462
|
-
this.props = { ...(init.props ?? {}) }
|
|
1463
|
-
this.text = init.text ?? ''
|
|
1464
|
-
}
|
|
1465
|
-
set(name: string, value: unknown): this { this.props = { ...this.props, [name]: value }; return this }
|
|
1466
|
-
get(name: string): unknown { return this.props[name] }
|
|
1467
|
-
setText(value: string): this { this.text = value; return this }
|
|
1468
|
-
setLabel(value: string): this { this.text = value; return this }
|
|
1469
|
-
onEvent(name: string, fn: (e: Event) => void): this { this.on = { ...this.on, [name.toLowerCase()]: fn }; return this }
|
|
1470
|
-
onClick(fn: (e: Event) => void): this { return this.onEvent('click', fn) }
|
|
1471
|
-
fire(name: string, e: Event): void { this.on[name.toLowerCase()]?.(e) }
|
|
1472
|
-
}
|
|
1473
|
-
|
|
1474
|
-
/** An imperative, reactive handle over a data-bound block (chart, KPI, gauge,
|
|
1475
|
-
* pivot, ...). By default it mirrors the screen's dataset; \`setData(rows)\` pins
|
|
1476
|
-
* an override so page code can feed the block its own rows (a filtered slice, a
|
|
1477
|
-
* fetch result), and \`clear()\` returns it to the screen dataset. */
|
|
1478
|
-
export class DataHandle<T = Record<string, unknown>> {
|
|
1479
|
-
#override = $state<T[] | null>(null)
|
|
1480
|
-
#fallback: () => T[]
|
|
1481
|
-
constructor(fallback: () => T[]) { this.#fallback = fallback }
|
|
1482
|
-
/** The rows the block renders: the override if set, else the screen dataset. */
|
|
1483
|
-
get rows(): T[] { return this.#override ?? this.#fallback() }
|
|
1484
|
-
/** Feed this block its own rows (overrides the screen dataset until cleared). */
|
|
1485
|
-
setData(rows: T[]): void { this.#override = rows }
|
|
1486
|
-
/** Drop the override; the block follows the screen dataset again. */
|
|
1487
|
-
clear(): void { this.#override = null }
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
/** A DataHandle whose fallback is the screen dataset getter. */
|
|
1491
|
-
export function dataHandle<T>(fallback: () => T[]): DataHandle<T> { return new DataHandle<T>(fallback) }
|
|
1492
|
-
|
|
1493
|
-
/** Event subscriptions for the Grid (\`ctx.grid.onCellClick = (e) => {}\`). The keys
|
|
1494
|
-
* are lowercased on both store + dispatch so any on<Event> casing round-trips. */
|
|
1495
|
-
class GridEvents {
|
|
1496
|
-
on: Record<string, (...args: unknown[]) => void> = {}
|
|
1497
|
-
fire(name: string, ...args: unknown[]): void { this.on[name.toLowerCase()]?.(...args) }
|
|
1498
|
-
}
|
|
1499
|
-
/** Wrap the Grid's real SvGridApi so page code gets BOTH its methods
|
|
1500
|
-
* (\`ctx.grid.exportCsv()\`) and event subscription (\`ctx.grid.onRowClick = fn\`).
|
|
1501
|
-
* The api arrives asynchronously (onApiReady), so it is read lazily via \`getApi\`. */
|
|
1502
|
-
export function gridHandle<A extends object>(getApi: () => A | null): A & GridEvents {
|
|
1503
|
-
const ev = new GridEvents()
|
|
1504
|
-
return new Proxy(ev, {
|
|
1505
|
-
get(t, k) {
|
|
1506
|
-
if (k in t) { const v = (t as Record<string | symbol, unknown>)[k]; return typeof v === 'function' ? (v as (...a: unknown[]) => unknown).bind(t) : v }
|
|
1507
|
-
const api = getApi() as Record<string | symbol, unknown> | null
|
|
1508
|
-
if (!api) return undefined
|
|
1509
|
-
const v = api[k]
|
|
1510
|
-
if (typeof v === 'function') return (v as (...a: unknown[]) => unknown).bind(api)
|
|
1511
|
-
// A grid PROP read (ctx.grid.sortable) -> its effective value via getOption.
|
|
1512
|
-
if (v === undefined && typeof k === 'string' && !/^on[A-Z]/.test(k) && typeof api.getOption === 'function') return (api.getOption as (key: unknown) => unknown)(k)
|
|
1513
|
-
return v
|
|
1514
|
-
},
|
|
1515
|
-
set(t, k, val) {
|
|
1516
|
-
// onRowClick = fn / oncellclick = fn -> subscribe (any casing; keys lowercase).
|
|
1517
|
-
if (typeof k === 'string' && /^on[A-Za-z]/.test(k)) { t.on = { ...t.on, [k.slice(2).toLowerCase()]: val as (...a: unknown[]) => void }; return true }
|
|
1518
|
-
const api = getApi() as Record<string | symbol, unknown> | null
|
|
1519
|
-
// ctx.grid.sortable = true -> the grid's reactive option channel (setOption writes
|
|
1520
|
-
// a $state override the grid merges over its props). Falls back to a direct set for
|
|
1521
|
-
// an older grid build without setOption.
|
|
1522
|
-
if (api && typeof api.setOption === 'function') { (api.setOption as (key: unknown, value: unknown) => void)(k, val); return true }
|
|
1523
|
-
if (api) api[k] = val
|
|
1524
|
-
return true
|
|
1525
|
-
},
|
|
1526
|
-
}) as A & GridEvents
|
|
1527
|
-
}
|
|
1528
|
-
|
|
1529
|
-
/** A handle plus dynamic setX / onX helpers and \`el.onclick = fn\` assignment. */
|
|
1530
|
-
export type Handle = ComponentHandle & Record<string, any>
|
|
1531
|
-
|
|
1532
|
-
export function handle(init: { props?: Record<string, unknown>; text?: string }): Handle {
|
|
1533
|
-
const h = new ComponentHandle(init)
|
|
1534
|
-
return new Proxy(h, {
|
|
1535
|
-
get(t, k) {
|
|
1536
|
-
if (Reflect.has(t, k)) { const v = (t as unknown as Record<string, unknown>)[k as string]; return typeof v === 'function' ? v.bind(t) : v }
|
|
1537
|
-
if (typeof k === 'string' && /^set[A-Z]/.test(k)) { const p = k[3]!.toLowerCase() + k.slice(4); return (v: unknown) => t.set(p, v) }
|
|
1538
|
-
// onEvent(fn) call form. onEvent lowercases, so onKeyDown maps to the 'keydown' the wrapper fires.
|
|
1539
|
-
if (typeof k === 'string' && /^on[A-Z]/.test(k)) { const e = k.slice(2); return (fn: (ev: Event) => void) => t.onEvent(e, fn) }
|
|
1540
|
-
return typeof k === 'string' ? t.props[k] : undefined
|
|
1541
|
-
},
|
|
1542
|
-
set(t, k, v) {
|
|
1543
|
-
// el.onKeyDown = fn / el.onclick = fn -> subscribe (onEvent lowercases the key).
|
|
1544
|
-
if (typeof k === 'string' && /^on[A-Za-z]/.test(k)) { t.onEvent(k.slice(2), v as (e: Event) => void); return true }
|
|
1545
|
-
if (k === 'text') { t.text = v as string; return true } // content, not a prop
|
|
1546
|
-
if (typeof k === 'string') t.set(k, v) // checkbox1.checked = true
|
|
1547
|
-
return true
|
|
1548
|
-
},
|
|
1549
|
-
}) as Handle
|
|
1550
|
-
}
|
|
1454
|
+
contents: `// Regenerated by SvGrid Studio.
|
|
1455
|
+
/** An imperative, reactive handle over a UI component. In page code you get one
|
|
1456
|
+
* per component (e.g. \`button1\`); mutate it and the component updates. */
|
|
1457
|
+
export class ComponentHandle {
|
|
1458
|
+
props = $state<Record<string, unknown>>({})
|
|
1459
|
+
text = $state('')
|
|
1460
|
+
on = $state<Record<string, (e: Event) => void>>({})
|
|
1461
|
+
constructor(init: { props?: Record<string, unknown>; text?: string }) {
|
|
1462
|
+
this.props = { ...(init.props ?? {}) }
|
|
1463
|
+
this.text = init.text ?? ''
|
|
1464
|
+
}
|
|
1465
|
+
set(name: string, value: unknown): this { this.props = { ...this.props, [name]: value }; return this }
|
|
1466
|
+
get(name: string): unknown { return this.props[name] }
|
|
1467
|
+
setText(value: string): this { this.text = value; return this }
|
|
1468
|
+
setLabel(value: string): this { this.text = value; return this }
|
|
1469
|
+
onEvent(name: string, fn: (e: Event) => void): this { this.on = { ...this.on, [name.toLowerCase()]: fn }; return this }
|
|
1470
|
+
onClick(fn: (e: Event) => void): this { return this.onEvent('click', fn) }
|
|
1471
|
+
fire(name: string, e: Event): void { this.on[name.toLowerCase()]?.(e) }
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/** An imperative, reactive handle over a data-bound block (chart, KPI, gauge,
|
|
1475
|
+
* pivot, ...). By default it mirrors the screen's dataset; \`setData(rows)\` pins
|
|
1476
|
+
* an override so page code can feed the block its own rows (a filtered slice, a
|
|
1477
|
+
* fetch result), and \`clear()\` returns it to the screen dataset. */
|
|
1478
|
+
export class DataHandle<T = Record<string, unknown>> {
|
|
1479
|
+
#override = $state<T[] | null>(null)
|
|
1480
|
+
#fallback: () => T[]
|
|
1481
|
+
constructor(fallback: () => T[]) { this.#fallback = fallback }
|
|
1482
|
+
/** The rows the block renders: the override if set, else the screen dataset. */
|
|
1483
|
+
get rows(): T[] { return this.#override ?? this.#fallback() }
|
|
1484
|
+
/** Feed this block its own rows (overrides the screen dataset until cleared). */
|
|
1485
|
+
setData(rows: T[]): void { this.#override = rows }
|
|
1486
|
+
/** Drop the override; the block follows the screen dataset again. */
|
|
1487
|
+
clear(): void { this.#override = null }
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/** A DataHandle whose fallback is the screen dataset getter. */
|
|
1491
|
+
export function dataHandle<T>(fallback: () => T[]): DataHandle<T> { return new DataHandle<T>(fallback) }
|
|
1492
|
+
|
|
1493
|
+
/** Event subscriptions for the Grid (\`ctx.grid.onCellClick = (e) => {}\`). The keys
|
|
1494
|
+
* are lowercased on both store + dispatch so any on<Event> casing round-trips. */
|
|
1495
|
+
class GridEvents {
|
|
1496
|
+
on: Record<string, (...args: unknown[]) => void> = {}
|
|
1497
|
+
fire(name: string, ...args: unknown[]): void { this.on[name.toLowerCase()]?.(...args) }
|
|
1498
|
+
}
|
|
1499
|
+
/** Wrap the Grid's real SvGridApi so page code gets BOTH its methods
|
|
1500
|
+
* (\`ctx.grid.exportCsv()\`) and event subscription (\`ctx.grid.onRowClick = fn\`).
|
|
1501
|
+
* The api arrives asynchronously (onApiReady), so it is read lazily via \`getApi\`. */
|
|
1502
|
+
export function gridHandle<A extends object>(getApi: () => A | null): A & GridEvents {
|
|
1503
|
+
const ev = new GridEvents()
|
|
1504
|
+
return new Proxy(ev, {
|
|
1505
|
+
get(t, k) {
|
|
1506
|
+
if (k in t) { const v = (t as Record<string | symbol, unknown>)[k]; return typeof v === 'function' ? (v as (...a: unknown[]) => unknown).bind(t) : v }
|
|
1507
|
+
const api = getApi() as Record<string | symbol, unknown> | null
|
|
1508
|
+
if (!api) return undefined
|
|
1509
|
+
const v = api[k]
|
|
1510
|
+
if (typeof v === 'function') return (v as (...a: unknown[]) => unknown).bind(api)
|
|
1511
|
+
// A grid PROP read (ctx.grid.sortable) -> its effective value via getOption.
|
|
1512
|
+
if (v === undefined && typeof k === 'string' && !/^on[A-Z]/.test(k) && typeof api.getOption === 'function') return (api.getOption as (key: unknown) => unknown)(k)
|
|
1513
|
+
return v
|
|
1514
|
+
},
|
|
1515
|
+
set(t, k, val) {
|
|
1516
|
+
// onRowClick = fn / oncellclick = fn -> subscribe (any casing; keys lowercase).
|
|
1517
|
+
if (typeof k === 'string' && /^on[A-Za-z]/.test(k)) { t.on = { ...t.on, [k.slice(2).toLowerCase()]: val as (...a: unknown[]) => void }; return true }
|
|
1518
|
+
const api = getApi() as Record<string | symbol, unknown> | null
|
|
1519
|
+
// ctx.grid.sortable = true -> the grid's reactive option channel (setOption writes
|
|
1520
|
+
// a $state override the grid merges over its props). Falls back to a direct set for
|
|
1521
|
+
// an older grid build without setOption.
|
|
1522
|
+
if (api && typeof api.setOption === 'function') { (api.setOption as (key: unknown, value: unknown) => void)(k, val); return true }
|
|
1523
|
+
if (api) api[k] = val
|
|
1524
|
+
return true
|
|
1525
|
+
},
|
|
1526
|
+
}) as A & GridEvents
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/** A handle plus dynamic setX / onX helpers and \`el.onclick = fn\` assignment. */
|
|
1530
|
+
export type Handle = ComponentHandle & Record<string, any>
|
|
1531
|
+
|
|
1532
|
+
export function handle(init: { props?: Record<string, unknown>; text?: string }): Handle {
|
|
1533
|
+
const h = new ComponentHandle(init)
|
|
1534
|
+
return new Proxy(h, {
|
|
1535
|
+
get(t, k) {
|
|
1536
|
+
if (Reflect.has(t, k)) { const v = (t as unknown as Record<string, unknown>)[k as string]; return typeof v === 'function' ? v.bind(t) : v }
|
|
1537
|
+
if (typeof k === 'string' && /^set[A-Z]/.test(k)) { const p = k[3]!.toLowerCase() + k.slice(4); return (v: unknown) => t.set(p, v) }
|
|
1538
|
+
// onEvent(fn) call form. onEvent lowercases, so onKeyDown maps to the 'keydown' the wrapper fires.
|
|
1539
|
+
if (typeof k === 'string' && /^on[A-Z]/.test(k)) { const e = k.slice(2); return (fn: (ev: Event) => void) => t.onEvent(e, fn) }
|
|
1540
|
+
return typeof k === 'string' ? t.props[k] : undefined
|
|
1541
|
+
},
|
|
1542
|
+
set(t, k, v) {
|
|
1543
|
+
// el.onKeyDown = fn / el.onclick = fn -> subscribe (onEvent lowercases the key).
|
|
1544
|
+
if (typeof k === 'string' && /^on[A-Za-z]/.test(k)) { t.onEvent(k.slice(2), v as (e: Event) => void); return true }
|
|
1545
|
+
if (k === 'text') { t.text = v as string; return true } // content, not a prop
|
|
1546
|
+
if (typeof k === 'string') t.set(k, v) // checkbox1.checked = true
|
|
1547
|
+
return true
|
|
1548
|
+
},
|
|
1549
|
+
}) as Handle
|
|
1550
|
+
}
|
|
1551
1551
|
`,
|
|
1552
1552
|
};
|
|
1553
1553
|
}
|
|
@@ -1678,12 +1678,12 @@ function screenContextFile(screen, rowType) {
|
|
|
1678
1678
|
return {
|
|
1679
1679
|
path: `src/routes/${screen.route}/page-context.ts`,
|
|
1680
1680
|
description: `Typed page context for "${screen.title}" (regenerated).`,
|
|
1681
|
-
contents: `// Regenerated by SvGrid Studio. Edits here are overwritten - write code in handlers.ts.
|
|
1682
|
-
${imports}${imports ? '\n\n' : ''}${typeDecls ? typeDecls + '\n\n' : ''}/** What onLoad(ctx) / onDestroy(ctx) give you: the Grid's real API, each block as a
|
|
1683
|
-
* handle, the screen dataset, and goto / params. */
|
|
1684
|
-
export type PageContext = {
|
|
1685
|
-
${members.join('\n')}
|
|
1686
|
-
}
|
|
1681
|
+
contents: `// Regenerated by SvGrid Studio. Edits here are overwritten - write code in handlers.ts.
|
|
1682
|
+
${imports}${imports ? '\n\n' : ''}${typeDecls ? typeDecls + '\n\n' : ''}/** What onLoad(ctx) / onDestroy(ctx) give you: the Grid's real API, each block as a
|
|
1683
|
+
* handle, the screen dataset, and goto / params. */
|
|
1684
|
+
export type PageContext = {
|
|
1685
|
+
${members.join('\n')}
|
|
1686
|
+
}
|
|
1687
1687
|
`,
|
|
1688
1688
|
};
|
|
1689
1689
|
}
|
|
@@ -1726,9 +1726,9 @@ function compiledMethodBodies(screen) {
|
|
|
1726
1726
|
* behavior here. Scaffolded once (userOwned) and never regenerated - the page
|
|
1727
1727
|
* imports it, never rewrites it. See HANDLERS-DESIGN.md. */
|
|
1728
1728
|
function screenHandlersFile(screen) {
|
|
1729
|
-
const header = `// Your code for the "${screen.title}" screen.
|
|
1730
|
-
// SvGrid Studio scaffolds this file once and never overwrites it - it's yours.
|
|
1731
|
-
// Design the screen visually in Studio; write its behavior here.
|
|
1729
|
+
const header = `// Your code for the "${screen.title}" screen.
|
|
1730
|
+
// SvGrid Studio scaffolds this file once and never overwrites it - it's yours.
|
|
1731
|
+
// Design the screen visually in Studio; write its behavior here.
|
|
1732
1732
|
${screenElementsManifest(screen)}`;
|
|
1733
1733
|
let body;
|
|
1734
1734
|
if (screen.handlersSource) {
|
|
@@ -1747,18 +1747,18 @@ ${screenElementsManifest(screen)}`;
|
|
|
1747
1747
|
const loadInner = loadRaw ? indentBody(loadRaw) : ' // Runs when the page mounts. Reach blocks via ctx.<name>, feed data via ctx.data / ctx.<chart>.setData(rows).';
|
|
1748
1748
|
const destroyRaw = compiled.onDestroy ?? screen.handlerBodies?.[ON_DESTROY]?.trim();
|
|
1749
1749
|
const destroyInner = destroyRaw ? indentBody(destroyRaw) : ' // Runs when the page unmounts. Clean up timers, subscriptions, aborts.';
|
|
1750
|
-
body = `import type { PageContext } from './page-context'
|
|
1751
|
-
|
|
1752
|
-
/** Runs when the page mounts. Reach blocks (ctx.grid.exportCsv(), ctx.chart1.setData(rows),
|
|
1753
|
-
* ctx.button1.onclick = () => {}), fetch data, navigate with ctx.goto. */
|
|
1754
|
-
export async function ${ON_LOAD}(ctx: PageContext): Promise<void> {
|
|
1755
|
-
${loadInner}
|
|
1756
|
-
}
|
|
1757
|
-
|
|
1758
|
-
/** Runs when the page unmounts. Clean up anything onLoad started. */
|
|
1759
|
-
export function ${ON_DESTROY}(ctx: PageContext): void {
|
|
1760
|
-
${destroyInner}
|
|
1761
|
-
}
|
|
1750
|
+
body = `import type { PageContext } from './page-context'
|
|
1751
|
+
|
|
1752
|
+
/** Runs when the page mounts. Reach blocks (ctx.grid.exportCsv(), ctx.chart1.setData(rows),
|
|
1753
|
+
* ctx.button1.onclick = () => {}), fetch data, navigate with ctx.goto. */
|
|
1754
|
+
export async function ${ON_LOAD}(ctx: PageContext): Promise<void> {
|
|
1755
|
+
${loadInner}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
/** Runs when the page unmounts. Clean up anything onLoad started. */
|
|
1759
|
+
export function ${ON_DESTROY}(ctx: PageContext): void {
|
|
1760
|
+
${destroyInner}
|
|
1761
|
+
}
|
|
1762
1762
|
`;
|
|
1763
1763
|
}
|
|
1764
1764
|
return {
|
|
@@ -1805,13 +1805,13 @@ function freestandingScreenPage(screen, accessEnabled, i18nEnabled) {
|
|
|
1805
1805
|
? `\n let rows = $state<RowData[]>([])\n let gridApi = $state<SvGridApi<any, any> | null>(null)\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature, rowSelectionFeature })\n const columns = $derived(rows.length ? Object.keys(rows[0]).map((field) => ({ field, header: field })) : [])`
|
|
1806
1806
|
: '';
|
|
1807
1807
|
const codeScript = hasCode
|
|
1808
|
-
? `\n ${gridScript ? gridScript.trimStart() + '\n ' : ''}${handleDecls ? handleDecls + '\n ' : ''}onMount(() => {
|
|
1809
|
-
// ctx is internal plumbing wired from this screen's blocks; the typed surface your
|
|
1810
|
-
// code uses lives in handlers.ts (PageContext). Component handles are runtime proxies,
|
|
1811
|
-
// so the cast bridges their dynamic shape to the typed context.
|
|
1812
|
-
const ctx = ${wiring.ctxLiteral} as unknown as PageContext
|
|
1813
|
-
handlers.${ON_LOAD}(ctx)
|
|
1814
|
-
return () => handlers.${ON_DESTROY}(ctx)
|
|
1808
|
+
? `\n ${gridScript ? gridScript.trimStart() + '\n ' : ''}${handleDecls ? handleDecls + '\n ' : ''}onMount(() => {
|
|
1809
|
+
// ctx is internal plumbing wired from this screen's blocks; the typed surface your
|
|
1810
|
+
// code uses lives in handlers.ts (PageContext). Component handles are runtime proxies,
|
|
1811
|
+
// so the cast bridges their dynamic shape to the typed context.
|
|
1812
|
+
const ctx = ${wiring.ctxLiteral} as unknown as PageContext
|
|
1813
|
+
handlers.${ON_LOAD}(ctx)
|
|
1814
|
+
return () => handlers.${ON_DESTROY}(ctx)
|
|
1815
1815
|
})`
|
|
1816
1816
|
: '';
|
|
1817
1817
|
// Grid events fire into ctx.grid so freestanding page code can subscribe too.
|
|
@@ -1825,14 +1825,14 @@ function freestandingScreenPage(screen, accessEnabled, i18nEnabled) {
|
|
|
1825
1825
|
return {
|
|
1826
1826
|
path: `src/routes/${screen.route}/+page.svelte`,
|
|
1827
1827
|
description: `${screen.title} screen (freestanding, no bound entity).`,
|
|
1828
|
-
contents: `<script lang="ts">
|
|
1829
|
-
${gridImport}${typeImport}${handleImport}${codeImport}${gotoImport}${pageStateImport}${accessImport}${i18nImport}${parts.join('\n\n ')}${codeScript}
|
|
1830
|
-
</script>
|
|
1831
|
-
|
|
1832
|
-
<h1 class="st__title">${title}</h1>
|
|
1833
|
-
${toolbar}<div class="st-screen${screenClassSuffix(screen)}">
|
|
1834
|
-
${content}
|
|
1835
|
-
</div>
|
|
1828
|
+
contents: `<script lang="ts">
|
|
1829
|
+
${gridImport}${typeImport}${handleImport}${codeImport}${gotoImport}${pageStateImport}${accessImport}${i18nImport}${parts.join('\n\n ')}${codeScript}
|
|
1830
|
+
</script>
|
|
1831
|
+
|
|
1832
|
+
<h1 class="st__title">${title}</h1>
|
|
1833
|
+
${toolbar}<div class="st-screen${screenClassSuffix(screen)}">
|
|
1834
|
+
${content}
|
|
1835
|
+
</div>
|
|
1836
1836
|
`,
|
|
1837
1837
|
};
|
|
1838
1838
|
}
|
|
@@ -2054,9 +2054,9 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
|
|
|
2054
2054
|
parts.push(actionHandlerScript(a));
|
|
2055
2055
|
if (needsController) {
|
|
2056
2056
|
const urlFilter = applyUrlFilters
|
|
2057
|
-
? `\n const sp = page.url.searchParams
|
|
2058
|
-
const _cols: Record<string, { operator: 'equals'; value: string }> = {}
|
|
2059
|
-
for (const _f of [${filterableFieldNames.map(jsStr).join(', ')}]) { const _v = sp.get(_f); if (_v != null) _cols[_f] = { operator: 'equals', value: _v } }
|
|
2057
|
+
? `\n const sp = page.url.searchParams
|
|
2058
|
+
const _cols: Record<string, { operator: 'equals'; value: string }> = {}
|
|
2059
|
+
for (const _f of [${filterableFieldNames.map(jsStr).join(', ')}]) { const _v = sp.get(_f); if (_v != null) _cols[_f] = { operator: 'equals', value: _v } }
|
|
2060
2060
|
if (Object.keys(_cols).length) controller.setFilter({ columns: _cols })`
|
|
2061
2061
|
: '';
|
|
2062
2062
|
const ctlBase = `createServerDataSource<${n.type}>(${n.sourceVar}, { pageSize: ${gridPageSize}, optimistic: true, getRowId: (r) => String((r as Record<string, unknown>)[idField]), onChange: (s) => (view = s) })`;
|
|
@@ -2066,12 +2066,12 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
|
|
|
2066
2066
|
? `const __ctl = ${ctlBase}\n const controller = { ...__ctl, createRow: async (i: Partial<${n.type}>) => { const r = await __ctl.createRow(i); gridCtx.fire('rowAdded', r); return r }, updateRow: async (id: string, p: Partial<${n.type}>) => { const r = await __ctl.updateRow(id, p); gridCtx.fire('rowUpdated', r); return r }, deleteRow: async (id: string) => { await __ctl.deleteRow(id); gridCtx.fire('rowDeleted', id) } }`
|
|
2067
2067
|
: `const controller = ${ctlBase}`;
|
|
2068
2068
|
const rtWiring = rtSupabase
|
|
2069
|
-
? `\n const __rt = createSupabaseRealtime({ client: supabaseClient as unknown as SupabaseRealtimeClientLike, table: ${jsStr(rtTable)}, debounceMs: 250, onChange: () => controller.refresh() })
|
|
2069
|
+
? `\n const __rt = createSupabaseRealtime({ client: supabaseClient as unknown as SupabaseRealtimeClientLike, table: ${jsStr(rtTable)}, debounceMs: 250, onChange: () => controller.refresh() })
|
|
2070
2070
|
return () => { __rt.unsubscribe(); controller.dispose() } })`
|
|
2071
2071
|
: `\n controller.refresh(); return () => controller.dispose() })`;
|
|
2072
|
-
parts.push(`const idField = ${n.schemaVar}.idField ?? 'id'
|
|
2073
|
-
let view = $state<ServerState<${n.type}>>({ rows: [], total: 0, loading: false, saving: false, error: null, pageIndex: 0, pageSize: ${gridPageSize}, pageCount: 1, sortModel: [], filterModel: {} })
|
|
2074
|
-
${ctlDecl}
|
|
2072
|
+
parts.push(`const idField = ${n.schemaVar}.idField ?? 'id'
|
|
2073
|
+
let view = $state<ServerState<${n.type}>>({ rows: [], total: 0, loading: false, saving: false, error: null, pageIndex: 0, pageSize: ${gridPageSize}, pageCount: 1, sortModel: [], filterModel: {} })
|
|
2074
|
+
${ctlDecl}
|
|
2075
2075
|
$effect(() => {${urlFilter}${rtSupabase ? '\n controller.refresh()' : ''}${rtWiring}`);
|
|
2076
2076
|
}
|
|
2077
2077
|
const actionSnippets = [];
|
|
@@ -2119,11 +2119,11 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
|
|
|
2119
2119
|
// SSR read screens get their rows from the server load (real SSR HTML);
|
|
2120
2120
|
// the SPA path fetches client-side after mount.
|
|
2121
2121
|
parts.push(ssrData
|
|
2122
|
-
? `const allRows = $derived(data.rows as ${n.type}[])
|
|
2122
|
+
? `const allRows = $derived(data.rows as ${n.type}[])
|
|
2123
2123
|
const allRowsReady = true`
|
|
2124
|
-
: `let allRows = $state<${n.type}[]>([])
|
|
2125
|
-
let allRowsReady = $state(false)
|
|
2126
|
-
async function loadAll() { allRows = [...(await ${n.sourceVar}.getRows({ startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} })).rows]; allRowsReady = true }
|
|
2124
|
+
: `let allRows = $state<${n.type}[]>([])
|
|
2125
|
+
let allRowsReady = $state(false)
|
|
2126
|
+
async function loadAll() { allRows = [...(await ${n.sourceVar}.getRows({ startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} })).rows]; allRowsReady = true }
|
|
2127
2127
|
loadAll()`);
|
|
2128
2128
|
}
|
|
2129
2129
|
// Tree-grid state/derivations - emitted after `allRows` so it's in scope.
|
|
@@ -2137,13 +2137,13 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
|
|
|
2137
2137
|
// Persist the arrangement to localStorage (restored after mount so SSR stays
|
|
2138
2138
|
// stable), unless the screen opts out - then the seeded layout is fixed.
|
|
2139
2139
|
const persistParts = panePersist
|
|
2140
|
-
? `
|
|
2141
|
-
function loadDock(): DockManagerState | null { try { const r = localStorage.getItem(${dockKey}); return r ? (JSON.parse(r) as DockManagerState) : null } catch { return null } }
|
|
2142
|
-
function saveDock(ws: DockManagerState) { try { localStorage.setItem(${dockKey}, JSON.stringify(ws)) } catch { /* storage unavailable */ } }
|
|
2140
|
+
? `
|
|
2141
|
+
function loadDock(): DockManagerState | null { try { const r = localStorage.getItem(${dockKey}); return r ? (JSON.parse(r) as DockManagerState) : null } catch { return null } }
|
|
2142
|
+
function saveDock(ws: DockManagerState) { try { localStorage.setItem(${dockKey}, JSON.stringify(ws)) } catch { /* storage unavailable */ } }
|
|
2143
2143
|
$effect(() => { const saved = loadDock(); if (saved) dockWorkspace = saved })`
|
|
2144
2144
|
: '';
|
|
2145
|
-
parts.push(`let dockWorkspace = $state<DockManagerState>(${JSON.stringify(dockState ?? { main: null, floating: [], autoHide: [] })})
|
|
2146
|
-
let dockNarrow = $state(false)${persistParts}
|
|
2145
|
+
parts.push(`let dockWorkspace = $state<DockManagerState>(${JSON.stringify(dockState ?? { main: null, floating: [], autoHide: [] })})
|
|
2146
|
+
let dockNarrow = $state(false)${persistParts}
|
|
2147
2147
|
$effect(() => { const mq = window.matchMedia('(max-width: 720px)'); const sync = () => (dockNarrow = mq.matches); sync(); mq.addEventListener('change', sync); return () => mq.removeEventListener('change', sync) })`);
|
|
2148
2148
|
}
|
|
2149
2149
|
for (const c of childList) {
|
|
@@ -2153,8 +2153,8 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
|
|
|
2153
2153
|
parts.push(`const ${v} = $derived(data.${v} as ${cn.type}[])`);
|
|
2154
2154
|
continue;
|
|
2155
2155
|
}
|
|
2156
|
-
parts.push(`let ${v} = $state<${cn.type}[]>([])
|
|
2157
|
-
async function load_${v}() { ${v} = [...(await ${cn.sourceVar}.getRows({ startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} })).rows] }
|
|
2156
|
+
parts.push(`let ${v} = $state<${cn.type}[]>([])
|
|
2157
|
+
async function load_${v}() { ${v} = [...(await ${cn.sourceVar}.getRows({ startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} })).rows] }
|
|
2158
2158
|
load_${v}()`);
|
|
2159
2159
|
}
|
|
2160
2160
|
// "On record saved" (formSubmit) steps run at the end of a save, with the
|
|
@@ -2167,11 +2167,11 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
|
|
|
2167
2167
|
const lookupsProp = relationFields.length
|
|
2168
2168
|
? `\n const lookups = { ${relationFields.map((f, i) => `${f.field}: ${lookupVars[i]}`).join(', ')} }`
|
|
2169
2169
|
: '';
|
|
2170
|
-
parts.push(`let editing = $state<${n.type} | null | undefined>(undefined)${lookupsProp}
|
|
2171
|
-
async function save({ mode, id, values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<${n.type}> }) {
|
|
2172
|
-
if (mode === 'create') { await controller.createRow({ [idField]: nextId('${n.idPrefix}'), ...values } as Partial<${n.type}>); controller.setPage(view.pageCount - 1) }
|
|
2173
|
-
else if (id) { await controller.updateRow(id, values) }${submitBody}
|
|
2174
|
-
editing = undefined${needsAllRows ? '\n await loadAll()' : ''}
|
|
2170
|
+
parts.push(`let editing = $state<${n.type} | null | undefined>(undefined)${lookupsProp}
|
|
2171
|
+
async function save({ mode, id, values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<${n.type}> }) {
|
|
2172
|
+
if (mode === 'create') { await controller.createRow({ [idField]: nextId('${n.idPrefix}'), ...values } as Partial<${n.type}>); controller.setPage(view.pageCount - 1) }
|
|
2173
|
+
else if (id) { await controller.updateRow(id, values) }${submitBody}
|
|
2174
|
+
editing = undefined${needsAllRows ? '\n await loadAll()' : ''}
|
|
2175
2175
|
}`);
|
|
2176
2176
|
}
|
|
2177
2177
|
// Standalone create form: a counter that both blanks the panel (it is the
|
|
@@ -2183,18 +2183,18 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
|
|
|
2183
2183
|
const after = nav
|
|
2184
2184
|
? `\n await goto('/${nav}')`
|
|
2185
2185
|
: '';
|
|
2186
|
-
parts.push(`let formSaves = $state(0)
|
|
2187
|
-
async function createRecord({ values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<${n.type}> }) {
|
|
2188
|
-
await controller.createRow({ [idField]: nextId('${n.idPrefix}'), ...values } as Partial<${n.type}>)${submitBody}
|
|
2189
|
-
formSaves += 1${after}
|
|
2186
|
+
parts.push(`let formSaves = $state(0)
|
|
2187
|
+
async function createRecord({ values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<${n.type}> }) {
|
|
2188
|
+
await controller.createRow({ [idField]: nextId('${n.idPrefix}'), ...values } as Partial<${n.type}>)${submitBody}
|
|
2189
|
+
formSaves += 1${after}
|
|
2190
2190
|
}`);
|
|
2191
2191
|
}
|
|
2192
2192
|
// Record panel: the row selected in the grid, plus (when editable) a save hook.
|
|
2193
2193
|
if (hasRecord) {
|
|
2194
|
-
parts.push(`let selectedRecord = $state<${n.type} | null>(null)${recordEditable ? `
|
|
2195
|
-
async function saveRecord({ id, values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<${n.type}> }) {
|
|
2196
|
-
if (id) { await controller.updateRow(id, values) }${submitBody}
|
|
2197
|
-
selectedRecord = null${needsAllRows ? '\n await loadAll()' : ''}
|
|
2194
|
+
parts.push(`let selectedRecord = $state<${n.type} | null>(null)${recordEditable ? `
|
|
2195
|
+
async function saveRecord({ id, values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<${n.type}> }) {
|
|
2196
|
+
if (id) { await controller.updateRow(id, values) }${submitBody}
|
|
2197
|
+
selectedRecord = null${needsAllRows ? '\n await loadAll()' : ''}
|
|
2198
2198
|
}` : ''}`);
|
|
2199
2199
|
}
|
|
2200
2200
|
// Filter panel(s): one facet-state object + an apply() that rebuilds the whole
|
|
@@ -2218,17 +2218,17 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
|
|
|
2218
2218
|
}
|
|
2219
2219
|
// Tree block: fold the flat rows into SvTree nodes by a self-referential parent.
|
|
2220
2220
|
if (has(allBlocks, 'tree')) {
|
|
2221
|
-
parts.push(`type TreeNode = { id: string; label: string; children: TreeNode[] }
|
|
2222
|
-
function toTreeNodes(rows: Record<string, unknown>[], idField: string, labelField: string, parentField: string): TreeNode[] {
|
|
2223
|
-
const byId = new Map<string, TreeNode>(rows.map((r) => [String(r[idField]), { id: String(r[idField]), label: String(r[labelField] ?? r[idField]), children: [] }]))
|
|
2224
|
-
const roots: TreeNode[] = []
|
|
2225
|
-
for (const r of rows) {
|
|
2226
|
-
const node = byId.get(String(r[idField]))!
|
|
2227
|
-
const pid = r[parentField] != null && r[parentField] !== '' ? String(r[parentField]) : null
|
|
2228
|
-
if (pid && pid !== node.id && byId.has(pid)) byId.get(pid)!.children.push(node)
|
|
2229
|
-
else roots.push(node)
|
|
2230
|
-
}
|
|
2231
|
-
return roots
|
|
2221
|
+
parts.push(`type TreeNode = { id: string; label: string; children: TreeNode[] }
|
|
2222
|
+
function toTreeNodes(rows: Record<string, unknown>[], idField: string, labelField: string, parentField: string): TreeNode[] {
|
|
2223
|
+
const byId = new Map<string, TreeNode>(rows.map((r) => [String(r[idField]), { id: String(r[idField]), label: String(r[labelField] ?? r[idField]), children: [] }]))
|
|
2224
|
+
const roots: TreeNode[] = []
|
|
2225
|
+
for (const r of rows) {
|
|
2226
|
+
const node = byId.get(String(r[idField]))!
|
|
2227
|
+
const pid = r[parentField] != null && r[parentField] !== '' ? String(r[parentField]) : null
|
|
2228
|
+
if (pid && pid !== node.id && byId.has(pid)) byId.get(pid)!.children.push(node)
|
|
2229
|
+
else roots.push(node)
|
|
2230
|
+
}
|
|
2231
|
+
return roots
|
|
2232
2232
|
}`);
|
|
2233
2233
|
}
|
|
2234
2234
|
// Code-behind: declare the block handles, then run onLoad on mount + onDestroy on
|
|
@@ -2237,10 +2237,10 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
|
|
|
2237
2237
|
if (codeWire) {
|
|
2238
2238
|
if (codeWire.decls.length)
|
|
2239
2239
|
parts.push(codeWire.decls.join('\n '));
|
|
2240
|
-
parts.push(`onMount(() => {
|
|
2241
|
-
const ctx = ${codeWire.ctxLiteral} as unknown as PageContext
|
|
2242
|
-
handlers.${ON_LOAD}(ctx)
|
|
2243
|
-
return () => handlers.${ON_DESTROY}(ctx)
|
|
2240
|
+
parts.push(`onMount(() => {
|
|
2241
|
+
const ctx = ${codeWire.ctxLiteral} as unknown as PageContext
|
|
2242
|
+
handlers.${ON_LOAD}(ctx)
|
|
2243
|
+
return () => handlers.${ON_DESTROY}(ctx)
|
|
2244
2244
|
})`);
|
|
2245
2245
|
}
|
|
2246
2246
|
// --- markup ---
|
|
@@ -2271,25 +2271,25 @@ function screenPage(schema, rawSchema, screen, resolve, rawResolve, accessEnable
|
|
|
2271
2271
|
}).filter(Boolean).join('\n')
|
|
2272
2272
|
: '';
|
|
2273
2273
|
const screenBody = isDock
|
|
2274
|
-
? `{#if dockNarrow}
|
|
2275
|
-
<div class="st-screen${screenClassSuffix(screen)}">
|
|
2276
|
-
${body}
|
|
2277
|
-
</div>
|
|
2278
|
-
{:else}
|
|
2279
|
-
<div class="st-dock">
|
|
2280
|
-
<SvDockManager bind:workspace={dockWorkspace}${panePersist ? ' onChange={(w) => saveDock(w)}' : ''}${paneAttrStr}>
|
|
2281
|
-
{#snippet pane(p)}
|
|
2282
|
-
${dockPanes}
|
|
2283
|
-
{/snippet}
|
|
2284
|
-
</SvDockManager>
|
|
2285
|
-
</div>
|
|
2274
|
+
? `{#if dockNarrow}
|
|
2275
|
+
<div class="st-screen${screenClassSuffix(screen)}">
|
|
2276
|
+
${body}
|
|
2277
|
+
</div>
|
|
2278
|
+
{:else}
|
|
2279
|
+
<div class="st-dock">
|
|
2280
|
+
<SvDockManager bind:workspace={dockWorkspace}${panePersist ? ' onChange={(w) => saveDock(w)}' : ''}${paneAttrStr}>
|
|
2281
|
+
{#snippet pane(p)}
|
|
2282
|
+
${dockPanes}
|
|
2283
|
+
{/snippet}
|
|
2284
|
+
</SvDockManager>
|
|
2285
|
+
</div>
|
|
2286
2286
|
{/if}`
|
|
2287
2287
|
: isCanvas
|
|
2288
|
-
? `<div class="st-canvas${screenClassSuffix(screen)}">
|
|
2289
|
-
${canvasBody}
|
|
2288
|
+
? `<div class="st-canvas${screenClassSuffix(screen)}">
|
|
2289
|
+
${canvasBody}
|
|
2290
2290
|
</div>`
|
|
2291
|
-
: `<div class="${screenLayoutOf(screen) === 'stack' ? 'st-stack' : 'st-screen'}${screenClassSuffix(screen)}">
|
|
2292
|
-
${body}
|
|
2291
|
+
: `<div class="${screenLayoutOf(screen) === 'stack' ? 'st-stack' : 'st-screen'}${screenClassSuffix(screen)}">
|
|
2292
|
+
${body}
|
|
2293
2293
|
</div>`;
|
|
2294
2294
|
const modal = wantsForm
|
|
2295
2295
|
? `\n\n{#if editing !== undefined}\n <SvGridEditPanel schema={${n.schemaVar}} row={editing}${relationFields.length ? ' {lookups}' : ''} presentation="${formPres}"${formLayoutProps} persistKey="${screen.route}" onSubmit={save} onCancel={() => (editing = undefined)} />\n{/if}`
|
|
@@ -2310,37 +2310,37 @@ ${body}
|
|
|
2310
2310
|
const title = i18nEnabled ? `{$t('screen.${screen.id}', ${JSON.stringify(screen.title)})}` : screen.title;
|
|
2311
2311
|
// Surface a failed data load (silent empty grid otherwise) with a retry.
|
|
2312
2312
|
const errorBanner = needsController
|
|
2313
|
-
? `\n{#if view.error}
|
|
2314
|
-
<div class="st-error" role="alert">
|
|
2315
|
-
<span>Couldn't load data. {view.error instanceof Error ? view.error.message : String(view.error)}</span>
|
|
2316
|
-
<button type="button" class="st-btn" onclick={() => controller.refresh()}>Retry</button>
|
|
2317
|
-
</div>
|
|
2313
|
+
? `\n{#if view.error}
|
|
2314
|
+
<div class="st-error" role="alert">
|
|
2315
|
+
<span>Couldn't load data. {view.error instanceof Error ? view.error.message : String(view.error)}</span>
|
|
2316
|
+
<button type="button" class="st-btn" onclick={() => controller.refresh()}>Retry</button>
|
|
2317
|
+
</div>
|
|
2318
2318
|
{/if}\n`
|
|
2319
2319
|
: '';
|
|
2320
2320
|
return {
|
|
2321
2321
|
path: `src/routes/${screen.route}/+page.svelte`,
|
|
2322
2322
|
description: `${screen.title} screen (${blocks.map((b) => b.config.kind).join(', ') || 'empty'}).`,
|
|
2323
|
-
contents: `<script lang="ts">
|
|
2324
|
-
${gridImports}${entImport}${connImport}${handleImport}${accessImport}${i18nImport}${gotoImport}${codeImport}${pageImport}${ssrData ? "import type { PageProps } from './$types'\n " : ''}import { ${schemaVarImports.join(', ')}, ${typeImports.map((t) => `type ${t}`).join(', ')} } from '$lib/schemas'
|
|
2325
|
-
${dataImports.length ? ` import { ${dataImports.join(', ')} } from '$lib/data'\n` : ''}
|
|
2326
|
-
${ssrData ? 'let { data }: PageProps = $props()\n\n ' : ''}${parts.join('\n\n ')}
|
|
2327
|
-
</script>
|
|
2328
|
-
|
|
2329
|
-
<h1 class="st__title">${title}</h1>
|
|
2330
|
-
${errorBanner}
|
|
2331
|
-
${toolbar}${screenBody}${modal}${actionSnippets.length ? '\n\n' + actionSnippets.join('\n\n') : ''}
|
|
2323
|
+
contents: `<script lang="ts">
|
|
2324
|
+
${gridImports}${entImport}${connImport}${handleImport}${accessImport}${i18nImport}${gotoImport}${codeImport}${pageImport}${ssrData ? "import type { PageProps } from './$types'\n " : ''}import { ${schemaVarImports.join(', ')}, ${typeImports.map((t) => `type ${t}`).join(', ')} } from '$lib/schemas'
|
|
2325
|
+
${dataImports.length ? ` import { ${dataImports.join(', ')} } from '$lib/data'\n` : ''}
|
|
2326
|
+
${ssrData ? 'let { data }: PageProps = $props()\n\n ' : ''}${parts.join('\n\n ')}
|
|
2327
|
+
</script>
|
|
2328
|
+
|
|
2329
|
+
<h1 class="st__title">${title}</h1>
|
|
2330
|
+
${errorBanner}
|
|
2331
|
+
${toolbar}${screenBody}${modal}${actionSnippets.length ? '\n\n' + actionSnippets.join('\n\n') : ''}
|
|
2332
2332
|
${(() => {
|
|
2333
|
-
const kpiCss = has(blocks, 'kpi') || has(blocks, 'gauge') || has(blocks, 'tree') ? ` .kpi { position: relative; display: flex; flex-direction: column; gap: 6px; padding: 16px 18px; background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); overflow: hidden; }
|
|
2334
|
-
.kpi__head { display: flex; align-items: center; justify-content: space-between; }
|
|
2335
|
-
.kpi__label { font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); text-transform: uppercase; letter-spacing: 0.03em; }
|
|
2336
|
-
.kpi__value { font-size: 28px; font-weight: 750; line-height: 1.1; color: var(--sg-fg, #0f172a); }
|
|
2337
|
-
.kpi__delta { align-self: flex-start; display: inline-flex; align-items: center; gap: 3px; padding: 2px 8px; border-radius: 999px; font-size: 11.5px; font-weight: 700; background: color-mix(in srgb, var(--sg-muted, #64748b) 14%, transparent); color: var(--sg-muted, #64748b); }
|
|
2338
|
-
.kpi__delta.is-up { background: color-mix(in srgb, #16a34a 15%, transparent); color: #16a34a; }
|
|
2339
|
-
.kpi__delta.is-down { background: color-mix(in srgb, #dc2626 15%, transparent); color: #dc2626; }
|
|
2340
|
-
.kpi__spark { width: 100%; height: 30px; margin-top: 2px; color: var(--sg-accent, #4f46e5); opacity: 0.85; }
|
|
2341
|
-
.gaugecard { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 16px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; }
|
|
2342
|
-
.gaugecard .kpi__label { align-self: flex-start; }
|
|
2343
|
-
.treecard { padding: 12px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; }
|
|
2333
|
+
const kpiCss = has(blocks, 'kpi') || has(blocks, 'gauge') || has(blocks, 'tree') ? ` .kpi { position: relative; display: flex; flex-direction: column; gap: 6px; padding: 16px 18px; background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); overflow: hidden; }
|
|
2334
|
+
.kpi__head { display: flex; align-items: center; justify-content: space-between; }
|
|
2335
|
+
.kpi__label { font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); text-transform: uppercase; letter-spacing: 0.03em; }
|
|
2336
|
+
.kpi__value { font-size: 28px; font-weight: 750; line-height: 1.1; color: var(--sg-fg, #0f172a); }
|
|
2337
|
+
.kpi__delta { align-self: flex-start; display: inline-flex; align-items: center; gap: 3px; padding: 2px 8px; border-radius: 999px; font-size: 11.5px; font-weight: 700; background: color-mix(in srgb, var(--sg-muted, #64748b) 14%, transparent); color: var(--sg-muted, #64748b); }
|
|
2338
|
+
.kpi__delta.is-up { background: color-mix(in srgb, #16a34a 15%, transparent); color: #16a34a; }
|
|
2339
|
+
.kpi__delta.is-down { background: color-mix(in srgb, #dc2626 15%, transparent); color: #dc2626; }
|
|
2340
|
+
.kpi__spark { width: 100%; height: 30px; margin-top: 2px; color: var(--sg-accent, #4f46e5); opacity: 0.85; }
|
|
2341
|
+
.gaugecard { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 16px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; }
|
|
2342
|
+
.gaugecard .kpi__label { align-self: flex-start; }
|
|
2343
|
+
.treecard { padding: 12px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; }
|
|
2344
2344
|
` : '';
|
|
2345
2345
|
const css = kpiCss + screenLayoutStyle(screen);
|
|
2346
2346
|
return css ? `\n<style>\n${css}</style>\n` : '';
|
|
@@ -2381,19 +2381,19 @@ function ssrReadServerFile(schema, screen, sourceKind, accessEnabled, screenIds,
|
|
|
2381
2381
|
return {
|
|
2382
2382
|
path: `src/routes/${screen.route}/+page.server.ts`,
|
|
2383
2383
|
description: `${screen.title} - SSR load (full dataset${children.length ? ' + child collections' : ''}).`,
|
|
2384
|
-
contents: `import type { PageServerLoad } from './$types'
|
|
2385
|
-
import type { ServerRequest } from '@svgrid/grid'
|
|
2386
|
-
${isSql ? "import { createKitDataSource } from '@svgrid/enterprise'\n" : ''}${rbac ? "import { error } from '@sveltejs/kit'\nimport { authorizeAction, getServerRole } from '$lib/access'\n" : ''}${memImports.length ? `import { ${memImports.join(', ')} } from '$lib/data'\n` : ''}
|
|
2387
|
-
// This screen renders on the server: real SSR HTML, not just a server-side
|
|
2388
|
-
// load. Stated explicitly so it holds whichever way the root layout is set.
|
|
2389
|
-
export const ssr = true
|
|
2390
|
-
${rbac ? `\nconst SCREEN_IDS = ${JSON.stringify(screenIds)}\n` : ''}
|
|
2391
|
-
const PAGE: ServerRequest = { startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} }
|
|
2392
|
-
|
|
2393
|
-
export const load: PageServerLoad = async (${isSql || rbac ? `{ ${[isSql ? 'fetch' : '', rbac ? 'locals' : ''].filter(Boolean).join(', ')} }` : ''}) => {
|
|
2394
|
-
${rbac ? ` if (!authorizeAction(getServerRole({ locals }), 'read', SCREEN_IDS)) throw error(403, 'Not allowed')\n` : ''} const rows = (await ${srcExpr(schema)}.getRows(PAGE)).rows
|
|
2395
|
-
${childLoads ? childLoads + '\n' : ''} return { rows${children.map((c) => `, ${mdChildVar(c.name)}`).join('')} }
|
|
2396
|
-
}
|
|
2384
|
+
contents: `import type { PageServerLoad } from './$types'
|
|
2385
|
+
import type { ServerRequest } from '@svgrid/grid'
|
|
2386
|
+
${isSql ? "import { createKitDataSource } from '@svgrid/enterprise'\n" : ''}${rbac ? "import { error } from '@sveltejs/kit'\nimport { authorizeAction, getServerRole } from '$lib/access'\n" : ''}${memImports.length ? `import { ${memImports.join(', ')} } from '$lib/data'\n` : ''}
|
|
2387
|
+
// This screen renders on the server: real SSR HTML, not just a server-side
|
|
2388
|
+
// load. Stated explicitly so it holds whichever way the root layout is set.
|
|
2389
|
+
export const ssr = true
|
|
2390
|
+
${rbac ? `\nconst SCREEN_IDS = ${JSON.stringify(screenIds)}\n` : ''}
|
|
2391
|
+
const PAGE: ServerRequest = { startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} }
|
|
2392
|
+
|
|
2393
|
+
export const load: PageServerLoad = async (${isSql || rbac ? `{ ${[isSql ? 'fetch' : '', rbac ? 'locals' : ''].filter(Boolean).join(', ')} }` : ''}) => {
|
|
2394
|
+
${rbac ? ` if (!authorizeAction(getServerRole({ locals }), 'read', SCREEN_IDS)) throw error(403, 'Not allowed')\n` : ''} const rows = (await ${srcExpr(schema)}.getRows(PAGE)).rows
|
|
2395
|
+
${childLoads ? childLoads + '\n' : ''} return { rows${children.map((c) => `, ${mdChildVar(c.name)}`).join('')} }
|
|
2396
|
+
}
|
|
2397
2397
|
`,
|
|
2398
2398
|
};
|
|
2399
2399
|
}
|
|
@@ -2408,34 +2408,34 @@ ${childLoads ? childLoads + '\n' : ''} return { rows${children.map((c) => `, ${
|
|
|
2408
2408
|
// sort/pagination back into the URL. Server-side validation runs in the actions.
|
|
2409
2409
|
// ---------------------------------------------------------------------------
|
|
2410
2410
|
/** URL search params -> a data-source request. Shared by every SSR route's load. */
|
|
2411
|
-
const SSR_QUERY_HELPER = `// Turn a page URL's search params into a data-source request (sort / filter /
|
|
2412
|
-
// page / size). The grid drives these params via goto(), so the server re-runs
|
|
2413
|
-
// load() - which makes every list view bookmarkable and functional with no JS.
|
|
2414
|
-
import type { ServerRequest } from '@svgrid/grid'
|
|
2415
|
-
|
|
2416
|
-
export function planFromSearchParams(url: URL, defaultPageSize = 25): ServerRequest {
|
|
2417
|
-
const sp = url.searchParams
|
|
2418
|
-
const pageIndex = Math.max(0, Number.parseInt(sp.get('page') ?? '', 10) || 0)
|
|
2419
|
-
const pageSize = Math.min(200, Math.max(1, Number.parseInt(sp.get('size') ?? '', 10) || defaultPageSize))
|
|
2420
|
-
const sortModel = (sp.get('sort') ?? '')
|
|
2421
|
-
.split(',')
|
|
2422
|
-
.filter(Boolean)
|
|
2423
|
-
.map((token) => {
|
|
2424
|
-
const [id, dir] = token.split(':')
|
|
2425
|
-
return { id: id!, desc: dir === 'desc' }
|
|
2426
|
-
})
|
|
2427
|
-
const columns: Record<string, { operator: 'contains'; value: string }> = {}
|
|
2428
|
-
for (const [k, v] of sp) if (k.startsWith('f_') && v) columns[k.slice(2)] = { operator: 'contains', value: v }
|
|
2429
|
-
const global = sp.get('q') ?? ''
|
|
2430
|
-
return {
|
|
2431
|
-
startRow: pageIndex * pageSize,
|
|
2432
|
-
endRow: pageIndex * pageSize + pageSize,
|
|
2433
|
-
pageIndex,
|
|
2434
|
-
pageSize,
|
|
2435
|
-
sortModel,
|
|
2436
|
-
filterModel: { ...(global ? { global } : {}), columns },
|
|
2437
|
-
}
|
|
2438
|
-
}
|
|
2411
|
+
const SSR_QUERY_HELPER = `// Turn a page URL's search params into a data-source request (sort / filter /
|
|
2412
|
+
// page / size). The grid drives these params via goto(), so the server re-runs
|
|
2413
|
+
// load() - which makes every list view bookmarkable and functional with no JS.
|
|
2414
|
+
import type { ServerRequest } from '@svgrid/grid'
|
|
2415
|
+
|
|
2416
|
+
export function planFromSearchParams(url: URL, defaultPageSize = 25): ServerRequest {
|
|
2417
|
+
const sp = url.searchParams
|
|
2418
|
+
const pageIndex = Math.max(0, Number.parseInt(sp.get('page') ?? '', 10) || 0)
|
|
2419
|
+
const pageSize = Math.min(200, Math.max(1, Number.parseInt(sp.get('size') ?? '', 10) || defaultPageSize))
|
|
2420
|
+
const sortModel = (sp.get('sort') ?? '')
|
|
2421
|
+
.split(',')
|
|
2422
|
+
.filter(Boolean)
|
|
2423
|
+
.map((token) => {
|
|
2424
|
+
const [id, dir] = token.split(':')
|
|
2425
|
+
return { id: id!, desc: dir === 'desc' }
|
|
2426
|
+
})
|
|
2427
|
+
const columns: Record<string, { operator: 'contains'; value: string }> = {}
|
|
2428
|
+
for (const [k, v] of sp) if (k.startsWith('f_') && v) columns[k.slice(2)] = { operator: 'contains', value: v }
|
|
2429
|
+
const global = sp.get('q') ?? ''
|
|
2430
|
+
return {
|
|
2431
|
+
startRow: pageIndex * pageSize,
|
|
2432
|
+
endRow: pageIndex * pageSize + pageSize,
|
|
2433
|
+
pageIndex,
|
|
2434
|
+
pageSize,
|
|
2435
|
+
sortModel,
|
|
2436
|
+
filterModel: { ...(global ? { global } : {}), columns },
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
2439
|
`;
|
|
2440
2440
|
function ssrQueryHelperFile() {
|
|
2441
2441
|
return { path: 'src/lib/server/query.ts', description: 'SSR: URL search params -> data-source request (sort/filter/page).', contents: SSR_QUERY_HELPER };
|
|
@@ -2551,60 +2551,60 @@ function emitSsrGridScreen(schema, screen, sourceKind, accessEnabled, screenIds,
|
|
|
2551
2551
|
const localsArg = rbac ? ', locals' : '';
|
|
2552
2552
|
const readGuard = rbac ? ` if (!authorizeAction(getServerRole({ locals }), 'read', SCREEN_IDS)) throw error(403, 'Not allowed')\n` : '';
|
|
2553
2553
|
const writeGuard = (action) => (rbac ? ` if (!authorizeAction(getServerRole({ locals }), '${action}', SCREEN_IDS)) return fail(403, { error: 'Not allowed' })\n` : '');
|
|
2554
|
-
const server = `import type { Actions, PageServerLoad } from './$types'
|
|
2555
|
-
import { fail${rbac ? ', error' : ''} } from '@sveltejs/kit'
|
|
2556
|
-
import { ${enterpriseImports} } from '@svgrid/enterprise'
|
|
2557
|
-
${rbac ? "import { authorizeAction, getServerRole } from '../lib/access'\n" : ''}${sourceImport}${schemaImport}
|
|
2558
|
-
import { planFromSearchParams } from '../lib/server/query'
|
|
2559
|
-
|
|
2560
|
-
// This screen renders on the server: real SSR HTML, not just a server-side
|
|
2561
|
-
// load. Stated explicitly so it holds whichever way the root layout is set.
|
|
2562
|
-
export const ssr = true
|
|
2563
|
-
${idConst}${rbac ? `\nconst SCREEN_IDS = ${JSON.stringify(screenIds)}` : ''}
|
|
2564
|
-
const FIELD_TYPES: Record<string, 'text' | 'number' | 'boolean'> = ${fieldTypesLit}
|
|
2565
|
-
${srcHelper}
|
|
2566
|
-
/** Read a submitted form into a typed partial row. Booleans come from checkbox
|
|
2567
|
-
* presence; numbers are coerced; empty values are dropped so they don't clobber. */
|
|
2568
|
-
function formToValues(fd: FormData): Record<string, unknown> {
|
|
2569
|
-
const values: Record<string, unknown> = {}
|
|
2570
|
-
for (const [field, type] of Object.entries(FIELD_TYPES)) {
|
|
2571
|
-
if (type === 'boolean') { values[field] = fd.get(field) != null; continue }
|
|
2572
|
-
const raw = fd.get(field)
|
|
2573
|
-
if (raw == null || raw === '') continue
|
|
2574
|
-
values[field] = type === 'number' ? Number(raw) : String(raw)
|
|
2575
|
-
}
|
|
2576
|
-
return values
|
|
2577
|
-
}
|
|
2578
|
-
|
|
2579
|
-
export const load: PageServerLoad = async ({ url${fetchArg}${localsArg} }) => {
|
|
2580
|
-
${readGuard} const plan = planFromSearchParams(url, ${pageSize})
|
|
2581
|
-
const { rows, rowCount } = await ${src}.getRows(plan)
|
|
2582
|
-
${relPrefetch ? relPrefetch + '\n' : ''} return { rows, total: rowCount, page: plan.pageIndex, size: plan.pageSize, sort: plan.sortModel${relReturn} }
|
|
2583
|
-
}
|
|
2584
|
-
|
|
2585
|
-
export const actions: Actions = {
|
|
2586
|
-
create: async ({ request${fetchArg}${localsArg} }) => {
|
|
2587
|
-
${writeGuard('create')} const values = ${readValues('await request.formData()')}
|
|
2588
|
-
const errors = await validateAll(${n.schemaVar}, values)
|
|
2589
|
-
if (Object.keys(errors).length) return fail(422, { errors, values })
|
|
2590
|
-
${createCall}
|
|
2591
|
-
return { ok: true }
|
|
2592
|
-
},
|
|
2593
|
-
update: async ({ request${fetchArg}${localsArg} }) => {
|
|
2594
|
-
const fd = await request.formData()
|
|
2595
|
-
${writeGuard('update')} const id = String(fd.get('__id') ?? '')
|
|
2596
|
-
const values = ${readValues('fd')}
|
|
2597
|
-
const errors = await validateAll(${n.schemaVar}, values)
|
|
2598
|
-
if (Object.keys(errors).length) return fail(422, { errors, values })
|
|
2599
|
-
await ${src}.updateRow(id, values)
|
|
2600
|
-
return { ok: true }
|
|
2601
|
-
},
|
|
2602
|
-
delete: async ({ request${fetchArg}${localsArg} }) => {
|
|
2603
|
-
${writeGuard('delete')} const fd = await request.formData()
|
|
2604
|
-
await ${src}.deleteRow(String(fd.get('__id') ?? ''))
|
|
2605
|
-
return { ok: true }
|
|
2606
|
-
},
|
|
2607
|
-
}
|
|
2554
|
+
const server = `import type { Actions, PageServerLoad } from './$types'
|
|
2555
|
+
import { fail${rbac ? ', error' : ''} } from '@sveltejs/kit'
|
|
2556
|
+
import { ${enterpriseImports} } from '@svgrid/enterprise'
|
|
2557
|
+
${rbac ? "import { authorizeAction, getServerRole } from '../lib/access'\n" : ''}${sourceImport}${schemaImport}
|
|
2558
|
+
import { planFromSearchParams } from '../lib/server/query'
|
|
2559
|
+
|
|
2560
|
+
// This screen renders on the server: real SSR HTML, not just a server-side
|
|
2561
|
+
// load. Stated explicitly so it holds whichever way the root layout is set.
|
|
2562
|
+
export const ssr = true
|
|
2563
|
+
${idConst}${rbac ? `\nconst SCREEN_IDS = ${JSON.stringify(screenIds)}` : ''}
|
|
2564
|
+
const FIELD_TYPES: Record<string, 'text' | 'number' | 'boolean'> = ${fieldTypesLit}
|
|
2565
|
+
${srcHelper}
|
|
2566
|
+
/** Read a submitted form into a typed partial row. Booleans come from checkbox
|
|
2567
|
+
* presence; numbers are coerced; empty values are dropped so they don't clobber. */
|
|
2568
|
+
function formToValues(fd: FormData): Record<string, unknown> {
|
|
2569
|
+
const values: Record<string, unknown> = {}
|
|
2570
|
+
for (const [field, type] of Object.entries(FIELD_TYPES)) {
|
|
2571
|
+
if (type === 'boolean') { values[field] = fd.get(field) != null; continue }
|
|
2572
|
+
const raw = fd.get(field)
|
|
2573
|
+
if (raw == null || raw === '') continue
|
|
2574
|
+
values[field] = type === 'number' ? Number(raw) : String(raw)
|
|
2575
|
+
}
|
|
2576
|
+
return values
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
export const load: PageServerLoad = async ({ url${fetchArg}${localsArg} }) => {
|
|
2580
|
+
${readGuard} const plan = planFromSearchParams(url, ${pageSize})
|
|
2581
|
+
const { rows, rowCount } = await ${src}.getRows(plan)
|
|
2582
|
+
${relPrefetch ? relPrefetch + '\n' : ''} return { rows, total: rowCount, page: plan.pageIndex, size: plan.pageSize, sort: plan.sortModel${relReturn} }
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
export const actions: Actions = {
|
|
2586
|
+
create: async ({ request${fetchArg}${localsArg} }) => {
|
|
2587
|
+
${writeGuard('create')} const values = ${readValues('await request.formData()')}
|
|
2588
|
+
const errors = await validateAll(${n.schemaVar}, values)
|
|
2589
|
+
if (Object.keys(errors).length) return fail(422, { errors, values })
|
|
2590
|
+
${createCall}
|
|
2591
|
+
return { ok: true }
|
|
2592
|
+
},
|
|
2593
|
+
update: async ({ request${fetchArg}${localsArg} }) => {
|
|
2594
|
+
const fd = await request.formData()
|
|
2595
|
+
${writeGuard('update')} const id = String(fd.get('__id') ?? '')
|
|
2596
|
+
const values = ${readValues('fd')}
|
|
2597
|
+
const errors = await validateAll(${n.schemaVar}, values)
|
|
2598
|
+
if (Object.keys(errors).length) return fail(422, { errors, values })
|
|
2599
|
+
await ${src}.updateRow(id, values)
|
|
2600
|
+
return { ok: true }
|
|
2601
|
+
},
|
|
2602
|
+
delete: async ({ request${fetchArg}${localsArg} }) => {
|
|
2603
|
+
${writeGuard('delete')} const fd = await request.formData()
|
|
2604
|
+
await ${src}.deleteRow(String(fd.get('__id') ?? ''))
|
|
2605
|
+
return { ok: true }
|
|
2606
|
+
},
|
|
2607
|
+
}
|
|
2608
2608
|
`;
|
|
2609
2609
|
// The facet panel becomes a plain GET form: each control is named for the
|
|
2610
2610
|
// param `planFromSearchParams` reads, so submitting navigates to a filtered
|
|
@@ -2652,15 +2652,15 @@ ${writeGuard('delete')} const fd = await request.formData()
|
|
|
2652
2652
|
return ` <label class="sk-facet">\n <span>${htmlEsc(f.label ?? f.field)}</span>\n ${control}\n </label>`;
|
|
2653
2653
|
})
|
|
2654
2654
|
.join('\n');
|
|
2655
|
-
return `
|
|
2656
|
-
<form method="GET" class="sk-facets">
|
|
2657
|
-
<input type="hidden" name="sort" value={page.url.searchParams.get('sort') ?? ''} />
|
|
2658
|
-
${controls}
|
|
2659
|
-
<div class="sk-facet-actions">
|
|
2660
|
-
<button type="submit">Filter</button>
|
|
2661
|
-
<a href={page.url.pathname}>Clear</a>
|
|
2662
|
-
</div>
|
|
2663
|
-
</form>
|
|
2655
|
+
return `
|
|
2656
|
+
<form method="GET" class="sk-facets">
|
|
2657
|
+
<input type="hidden" name="sort" value={page.url.searchParams.get('sort') ?? ''} />
|
|
2658
|
+
${controls}
|
|
2659
|
+
<div class="sk-facet-actions">
|
|
2660
|
+
<button type="submit">Filter</button>
|
|
2661
|
+
<a href={page.url.pathname}>Clear</a>
|
|
2662
|
+
</div>
|
|
2663
|
+
</form>
|
|
2664
2664
|
`;
|
|
2665
2665
|
})();
|
|
2666
2666
|
const facetCss = !facetForm
|
|
@@ -2713,10 +2713,10 @@ ${controls}
|
|
|
2713
2713
|
input = `<input type="${t}" name=${key} value={isCreate ? '' : (${row}[${key}] ?? '')}${req}${attrs(f)} />`;
|
|
2714
2714
|
}
|
|
2715
2715
|
const hint = f.input?.help;
|
|
2716
|
-
return ` <label class="sk-field${f.input?.span === 2 ? ' sk-field--wide' : ''}">
|
|
2717
|
-
<span>${htmlEsc(f.label ?? f.field)}${f.required ? ' *' : ''}</span>
|
|
2718
|
-
${input}
|
|
2719
|
-
${hint ? ` {#if !form?.errors?.[${key}]}<small class="sk-hint">${htmlEsc(hint)}</small>{/if}\n` : ''} {#if form?.errors?.[${key}]}<em class="sk-err">{form.errors[${key}]}</em>{/if}
|
|
2716
|
+
return ` <label class="sk-field${f.input?.span === 2 ? ' sk-field--wide' : ''}">
|
|
2717
|
+
<span>${htmlEsc(f.label ?? f.field)}${f.required ? ' *' : ''}</span>
|
|
2718
|
+
${input}
|
|
2719
|
+
${hint ? ` {#if !form?.errors?.[${key}]}<small class="sk-hint">${htmlEsc(hint)}</small>{/if}\n` : ''} {#if form?.errors?.[${key}]}<em class="sk-err">{form.errors[${key}]}</em>{/if}
|
|
2720
2720
|
</label>`;
|
|
2721
2721
|
});
|
|
2722
2722
|
// Group the inputs the way the schema's form layout says, so the server-
|
|
@@ -2756,145 +2756,145 @@ ${hint ? ` {#if !form?.errors?.[${key}]}<small class="sk-hint">${htmlEs
|
|
|
2756
2756
|
const open = g.collapsed
|
|
2757
2757
|
? `{${JSON.stringify(g.names)}.some((f) => form?.errors?.[f])}`
|
|
2758
2758
|
: '{true}';
|
|
2759
|
-
return ` <details class="sk-group sk-group--fold" style="--sk-cols: ${g.columns ?? formCols}" open=${open}>
|
|
2760
|
-
<summary>${htmlEsc(g.title)}</summary>
|
|
2761
|
-
${inner}
|
|
2759
|
+
return ` <details class="sk-group sk-group--fold" style="--sk-cols: ${g.columns ?? formCols}" open=${open}>
|
|
2760
|
+
<summary>${htmlEsc(g.title)}</summary>
|
|
2761
|
+
${inner}
|
|
2762
2762
|
</details>`;
|
|
2763
2763
|
}
|
|
2764
|
-
return ` <fieldset class="sk-group" style="--sk-cols: ${g.columns ?? formCols}">
|
|
2765
|
-
${g.title ? ` <legend>${htmlEsc(g.title)}</legend>\n` : ''}${inner}
|
|
2764
|
+
return ` <fieldset class="sk-group" style="--sk-cols: ${g.columns ?? formCols}">
|
|
2765
|
+
${g.title ? ` <legend>${htmlEsc(g.title)}</legend>\n` : ''}${inner}
|
|
2766
2766
|
</fieldset>`;
|
|
2767
2767
|
})
|
|
2768
2768
|
.join('\n')
|
|
2769
2769
|
: ` <div class="sk-group" style="--sk-cols: ${formCols}">\n${fieldBlocks.join('\n')}\n </div>`;
|
|
2770
|
-
const page = `<script lang="ts">
|
|
2771
|
-
import { SvGrid, renderSnippet, type ColumnDef, type CellContext } from '@svgrid/grid'
|
|
2772
|
-
import { schemaToColumns } from '@svgrid/enterprise'
|
|
2773
|
-
import { goto } from '$app/navigation'
|
|
2774
|
-
import { page } from '$app/state'
|
|
2775
|
-
import { enhance } from '$app/forms'
|
|
2776
|
-
import type { SubmitFunction } from '@sveltejs/kit'
|
|
2777
|
-
import { ${n.schemaVar}, type ${n.type} } from '$lib/schemas'
|
|
2778
|
-
import type { PageProps } from './$types'
|
|
2779
|
-
|
|
2780
|
-
let { data, form }: PageProps = $props()
|
|
2781
|
-
|
|
2782
|
-
const ID_FIELD = ${jsStr(idField)}
|
|
2783
|
-
const TITLE = ${jsStr(screen.title)}
|
|
2784
|
-
const NEW_LABEL = ${jsStr('New ' + n.label)}
|
|
2785
|
-
|
|
2786
|
-
// Grid columns from the schema + a row-actions column (Edit / Delete).
|
|
2787
|
-
const columns: ColumnDef<Record<string, never>, ${n.type}>[] = [
|
|
2788
|
-
...(schemaToColumns(${n.schemaVar}) as ColumnDef<Record<string, never>, ${n.type}>[]),
|
|
2789
|
-
{ id: '__actions', header: '', sortable: false, cell: (ctx: CellContext<${n.type}>) => renderSnippet(rowActions, { row: ctx.row.original }) },
|
|
2790
|
-
]
|
|
2791
|
-
|
|
2792
|
-
// null = no editor; 'create' = new row; a row = editing that row.
|
|
2793
|
-
let editing = $state<'create' | ${n.type} | null>(null)
|
|
2794
|
-
const isCreate = $derived(editing === 'create')
|
|
2795
|
-
|
|
2796
|
-
// Sort / paginate by writing to the URL - load() re-runs on the server.
|
|
2797
|
-
function setParams(patch: Record<string, string | null>) {
|
|
2798
|
-
const sp = new URLSearchParams(page.url.searchParams)
|
|
2799
|
-
for (const [k, v] of Object.entries(patch)) { if (v == null) sp.delete(k); else sp.set(k, v) }
|
|
2800
|
-
const q = sp.toString()
|
|
2801
|
-
void goto(q ? \`?\${q}\` : page.url.pathname, { keepFocus: true, noScroll: true })
|
|
2802
|
-
}
|
|
2803
|
-
${wantsFilter ? `
|
|
2804
|
-
// Filter via the URL (q = global search, f_<col> = per-column). The server's
|
|
2805
|
-
// planFromSearchParams reads these, so load() re-filters; reset to the first page.
|
|
2806
|
-
function applyFilters(f: { global: string; columns: Array<{ id: string; value: string }> }) {
|
|
2807
|
-
const sp = new URLSearchParams(page.url.searchParams)
|
|
2808
|
-
for (const k of [...sp.keys()]) if (k === 'q' || k.startsWith('f_')) sp.delete(k)
|
|
2809
|
-
if (f.global) sp.set('q', f.global)
|
|
2810
|
-
for (const c of f.columns) if (c.value) sp.set('f_' + c.id, c.value)
|
|
2811
|
-
sp.delete('page')
|
|
2812
|
-
const q = sp.toString()
|
|
2813
|
-
void goto(q ? \`?\${q}\` : page.url.pathname, { keepFocus: true, noScroll: true })
|
|
2814
|
-
}
|
|
2815
|
-
` : ''}
|
|
2816
|
-
// Progressive enhancement: post to the action, keep typed values on error,
|
|
2817
|
-
// close the editor on success (load re-runs automatically, refreshing the grid).
|
|
2818
|
-
const onSubmit: SubmitFunction = () => async ({ result, update }) => {
|
|
2819
|
-
await update({ reset: false })
|
|
2820
|
-
if (result.type === 'success') editing = null
|
|
2821
|
-
}
|
|
2822
|
-
</script>
|
|
2823
|
-
|
|
2824
|
-
{#snippet rowActions({ row }: { row: ${n.type} })}
|
|
2825
|
-
<div class="sk-rowact">
|
|
2826
|
-
<button type="button" class="sk-link" onclick={() => (editing = row)}>Edit</button>
|
|
2827
|
-
<form method="POST" action="?/delete" use:enhance={onSubmit} style="display:contents">
|
|
2828
|
-
<input type="hidden" name="__id" value={(row as Record<string, unknown>)[ID_FIELD] as string} />
|
|
2829
|
-
<button type="submit" class="sk-link sk-danger">Delete</button>
|
|
2830
|
-
</form>
|
|
2831
|
-
</div>
|
|
2832
|
-
{/snippet}
|
|
2833
|
-
|
|
2834
|
-
<header class="sk-head">
|
|
2835
|
-
<h1>{TITLE}</h1>
|
|
2836
|
-
<button type="button" class="sk-btn sk-btn--primary" onclick={() => (editing = 'create')}>{NEW_LABEL}</button>
|
|
2837
|
-
</header>
|
|
2838
|
-
${facetForm}
|
|
2839
|
-
<SvGrid
|
|
2840
|
-
data={data.rows}
|
|
2841
|
-
{columns}
|
|
2842
|
-
externalSort
|
|
2843
|
-
initialSorting={data.sort}
|
|
2844
|
-
externalPagination${wantsFilter ? '\n filterable\n externalFilter\n onFiltersChange={applyFilters}' : ''}
|
|
2845
|
-
rowCount={data.total}
|
|
2846
|
-
pageIndex={data.page}
|
|
2847
|
-
pageSize={data.size}
|
|
2848
|
-
onSortingChange={(s) => setParams({ sort: s.map((x) => \`\${x.id}:\${x.desc ? 'desc' : 'asc'}\`).join(',') || null, page: null })}
|
|
2849
|
-
onPaginationChange={(p) => setParams({ page: String(p.pageIndex), size: String(p.pageSize) })}
|
|
2850
|
-
/>
|
|
2851
|
-
|
|
2852
|
-
{#if editing}
|
|
2853
|
-
<div class="sk-overlay">
|
|
2854
|
-
<form method="POST" action={isCreate ? '?/create' : '?/update'} class="sk-form" use:enhance={onSubmit}>
|
|
2855
|
-
<h2>{isCreate ? NEW_LABEL : 'Edit ${htmlEsc(n.label)}'}</h2>
|
|
2856
|
-
{#if !isCreate}<input type="hidden" name="__id" value={${row}[ID_FIELD] as string} />{/if}
|
|
2857
|
-
${fieldsMarkup}
|
|
2858
|
-
<div class="sk-form__actions">
|
|
2859
|
-
<button type="button" class="sk-btn" onclick={() => (editing = null)}>Cancel</button>
|
|
2860
|
-
<button type="submit" class="sk-btn sk-btn--primary">Save</button>
|
|
2861
|
-
</div>
|
|
2862
|
-
</form>
|
|
2863
|
-
</div>
|
|
2864
|
-
{/if}
|
|
2865
|
-
|
|
2866
|
-
<style>
|
|
2867
|
-
.sk-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
|
2868
|
-
.sk-head h1 { margin: 0; font-size: 20px; }
|
|
2869
|
-
.sk-btn { font: inherit; padding: 7px 14px; border-radius: 8px; border: 1px solid var(--sg-border, #cbd5e1); background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); cursor: pointer; }
|
|
2870
|
-
.sk-btn--primary { background: var(--sg-accent, #4f46e5); border-color: var(--sg-accent, #4f46e5); color: #fff; }
|
|
2871
|
-
${facetCss} .sk-rowact { display: flex; gap: 10px; }
|
|
2872
|
-
.sk-link { background: none; border: none; padding: 0; font: inherit; color: var(--sg-accent, #4f46e5); cursor: pointer; }
|
|
2873
|
-
.sk-danger { color: #dc2626; }
|
|
2874
|
-
.sk-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.4); display: grid; place-items: center; z-index: 50; }
|
|
2875
|
-
.sk-form { width: min(480px, 92vw); max-height: 90vh; overflow: auto; background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); border-radius: 12px; padding: 22px; display: flex; flex-direction: column; gap: 12px; box-shadow: 0 24px 60px -20px rgba(15, 23, 42, 0.5); }
|
|
2876
|
-
.sk-form h2 { margin: 0 0 4px; font-size: 16px; }
|
|
2877
|
-
.sk-group { display: grid; grid-template-columns: repeat(var(--sk-cols, 1), minmax(0, 1fr)); gap: 12px; border: 0; padding: 0; margin: 0; min-width: 0; }
|
|
2878
|
-
.sk-group + .sk-group { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--sg-border, #e2e8f0); }
|
|
2879
|
-
.sk-group legend { grid-column: 1 / -1; padding: 0; font-size: 12px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; color: var(--sg-muted, #64748b); }
|
|
2880
|
-
.sk-group__desc { grid-column: 1 / -1; margin: 0; font-size: 12px; color: var(--sg-muted, #64748b); }
|
|
2881
|
-
/* A foldable group is a native <details>, so it works with JavaScript off.
|
|
2882
|
-
display:grid on the element itself would lay the <summary> out as a grid
|
|
2883
|
-
item and break the disclosure, so the grid moves to [open] children. */
|
|
2884
|
-
.sk-group--fold { display: block; }
|
|
2885
|
-
.sk-group--fold > summary { grid-column: 1 / -1; margin-bottom: 12px; font-size: 13.5px; font-weight: 650; color: var(--sg-fg, #0f172a); cursor: pointer; list-style-position: inside; }
|
|
2886
|
-
.sk-group--fold[open] { display: grid; }
|
|
2887
|
-
.sk-group--fold[open] > summary { margin-bottom: 0; }
|
|
2888
|
-
.sk-field--wide { grid-column: 1 / -1; }
|
|
2889
|
-
.sk-hint { color: var(--sg-muted, #64748b); font-size: 11.5px; }
|
|
2890
|
-
@media (max-width: 560px) { .sk-group { grid-template-columns: 1fr; } }
|
|
2891
|
-
.sk-field { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
|
|
2892
|
-
.sk-field span { color: var(--sg-muted, #64748b); }
|
|
2893
|
-
.sk-field input, .sk-field select { font: inherit; padding: 7px 9px; border-radius: 8px; border: 1px solid var(--sg-border, #cbd5e1); background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
|
|
2894
|
-
.sk-field input[type='checkbox'] { align-self: flex-start; width: auto; }
|
|
2895
|
-
.sk-err { color: #dc2626; font-size: 12px; font-style: normal; }
|
|
2896
|
-
.sk-form__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; }
|
|
2897
|
-
</style>
|
|
2770
|
+
const page = `<script lang="ts">
|
|
2771
|
+
import { SvGrid, renderSnippet, type ColumnDef, type CellContext } from '@svgrid/grid'
|
|
2772
|
+
import { schemaToColumns } from '@svgrid/enterprise'
|
|
2773
|
+
import { goto } from '$app/navigation'
|
|
2774
|
+
import { page } from '$app/state'
|
|
2775
|
+
import { enhance } from '$app/forms'
|
|
2776
|
+
import type { SubmitFunction } from '@sveltejs/kit'
|
|
2777
|
+
import { ${n.schemaVar}, type ${n.type} } from '$lib/schemas'
|
|
2778
|
+
import type { PageProps } from './$types'
|
|
2779
|
+
|
|
2780
|
+
let { data, form }: PageProps = $props()
|
|
2781
|
+
|
|
2782
|
+
const ID_FIELD = ${jsStr(idField)}
|
|
2783
|
+
const TITLE = ${jsStr(screen.title)}
|
|
2784
|
+
const NEW_LABEL = ${jsStr('New ' + n.label)}
|
|
2785
|
+
|
|
2786
|
+
// Grid columns from the schema + a row-actions column (Edit / Delete).
|
|
2787
|
+
const columns: ColumnDef<Record<string, never>, ${n.type}>[] = [
|
|
2788
|
+
...(schemaToColumns(${n.schemaVar}) as ColumnDef<Record<string, never>, ${n.type}>[]),
|
|
2789
|
+
{ id: '__actions', header: '', sortable: false, cell: (ctx: CellContext<${n.type}>) => renderSnippet(rowActions, { row: ctx.row.original }) },
|
|
2790
|
+
]
|
|
2791
|
+
|
|
2792
|
+
// null = no editor; 'create' = new row; a row = editing that row.
|
|
2793
|
+
let editing = $state<'create' | ${n.type} | null>(null)
|
|
2794
|
+
const isCreate = $derived(editing === 'create')
|
|
2795
|
+
|
|
2796
|
+
// Sort / paginate by writing to the URL - load() re-runs on the server.
|
|
2797
|
+
function setParams(patch: Record<string, string | null>) {
|
|
2798
|
+
const sp = new URLSearchParams(page.url.searchParams)
|
|
2799
|
+
for (const [k, v] of Object.entries(patch)) { if (v == null) sp.delete(k); else sp.set(k, v) }
|
|
2800
|
+
const q = sp.toString()
|
|
2801
|
+
void goto(q ? \`?\${q}\` : page.url.pathname, { keepFocus: true, noScroll: true })
|
|
2802
|
+
}
|
|
2803
|
+
${wantsFilter ? `
|
|
2804
|
+
// Filter via the URL (q = global search, f_<col> = per-column). The server's
|
|
2805
|
+
// planFromSearchParams reads these, so load() re-filters; reset to the first page.
|
|
2806
|
+
function applyFilters(f: { global: string; columns: Array<{ id: string; value: string }> }) {
|
|
2807
|
+
const sp = new URLSearchParams(page.url.searchParams)
|
|
2808
|
+
for (const k of [...sp.keys()]) if (k === 'q' || k.startsWith('f_')) sp.delete(k)
|
|
2809
|
+
if (f.global) sp.set('q', f.global)
|
|
2810
|
+
for (const c of f.columns) if (c.value) sp.set('f_' + c.id, c.value)
|
|
2811
|
+
sp.delete('page')
|
|
2812
|
+
const q = sp.toString()
|
|
2813
|
+
void goto(q ? \`?\${q}\` : page.url.pathname, { keepFocus: true, noScroll: true })
|
|
2814
|
+
}
|
|
2815
|
+
` : ''}
|
|
2816
|
+
// Progressive enhancement: post to the action, keep typed values on error,
|
|
2817
|
+
// close the editor on success (load re-runs automatically, refreshing the grid).
|
|
2818
|
+
const onSubmit: SubmitFunction = () => async ({ result, update }) => {
|
|
2819
|
+
await update({ reset: false })
|
|
2820
|
+
if (result.type === 'success') editing = null
|
|
2821
|
+
}
|
|
2822
|
+
</script>
|
|
2823
|
+
|
|
2824
|
+
{#snippet rowActions({ row }: { row: ${n.type} })}
|
|
2825
|
+
<div class="sk-rowact">
|
|
2826
|
+
<button type="button" class="sk-link" onclick={() => (editing = row)}>Edit</button>
|
|
2827
|
+
<form method="POST" action="?/delete" use:enhance={onSubmit} style="display:contents">
|
|
2828
|
+
<input type="hidden" name="__id" value={(row as Record<string, unknown>)[ID_FIELD] as string} />
|
|
2829
|
+
<button type="submit" class="sk-link sk-danger">Delete</button>
|
|
2830
|
+
</form>
|
|
2831
|
+
</div>
|
|
2832
|
+
{/snippet}
|
|
2833
|
+
|
|
2834
|
+
<header class="sk-head">
|
|
2835
|
+
<h1>{TITLE}</h1>
|
|
2836
|
+
<button type="button" class="sk-btn sk-btn--primary" onclick={() => (editing = 'create')}>{NEW_LABEL}</button>
|
|
2837
|
+
</header>
|
|
2838
|
+
${facetForm}
|
|
2839
|
+
<SvGrid
|
|
2840
|
+
data={data.rows}
|
|
2841
|
+
{columns}
|
|
2842
|
+
externalSort
|
|
2843
|
+
initialSorting={data.sort}
|
|
2844
|
+
externalPagination${wantsFilter ? '\n filterable\n externalFilter\n onFiltersChange={applyFilters}' : ''}
|
|
2845
|
+
rowCount={data.total}
|
|
2846
|
+
pageIndex={data.page}
|
|
2847
|
+
pageSize={data.size}
|
|
2848
|
+
onSortingChange={(s) => setParams({ sort: s.map((x) => \`\${x.id}:\${x.desc ? 'desc' : 'asc'}\`).join(',') || null, page: null })}
|
|
2849
|
+
onPaginationChange={(p) => setParams({ page: String(p.pageIndex), size: String(p.pageSize) })}
|
|
2850
|
+
/>
|
|
2851
|
+
|
|
2852
|
+
{#if editing}
|
|
2853
|
+
<div class="sk-overlay">
|
|
2854
|
+
<form method="POST" action={isCreate ? '?/create' : '?/update'} class="sk-form" use:enhance={onSubmit}>
|
|
2855
|
+
<h2>{isCreate ? NEW_LABEL : 'Edit ${htmlEsc(n.label)}'}</h2>
|
|
2856
|
+
{#if !isCreate}<input type="hidden" name="__id" value={${row}[ID_FIELD] as string} />{/if}
|
|
2857
|
+
${fieldsMarkup}
|
|
2858
|
+
<div class="sk-form__actions">
|
|
2859
|
+
<button type="button" class="sk-btn" onclick={() => (editing = null)}>Cancel</button>
|
|
2860
|
+
<button type="submit" class="sk-btn sk-btn--primary">Save</button>
|
|
2861
|
+
</div>
|
|
2862
|
+
</form>
|
|
2863
|
+
</div>
|
|
2864
|
+
{/if}
|
|
2865
|
+
|
|
2866
|
+
<style>
|
|
2867
|
+
.sk-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
|
2868
|
+
.sk-head h1 { margin: 0; font-size: 20px; }
|
|
2869
|
+
.sk-btn { font: inherit; padding: 7px 14px; border-radius: 8px; border: 1px solid var(--sg-border, #cbd5e1); background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); cursor: pointer; }
|
|
2870
|
+
.sk-btn--primary { background: var(--sg-accent, #4f46e5); border-color: var(--sg-accent, #4f46e5); color: #fff; }
|
|
2871
|
+
${facetCss} .sk-rowact { display: flex; gap: 10px; }
|
|
2872
|
+
.sk-link { background: none; border: none; padding: 0; font: inherit; color: var(--sg-accent, #4f46e5); cursor: pointer; }
|
|
2873
|
+
.sk-danger { color: #dc2626; }
|
|
2874
|
+
.sk-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.4); display: grid; place-items: center; z-index: 50; }
|
|
2875
|
+
.sk-form { width: min(480px, 92vw); max-height: 90vh; overflow: auto; background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); border-radius: 12px; padding: 22px; display: flex; flex-direction: column; gap: 12px; box-shadow: 0 24px 60px -20px rgba(15, 23, 42, 0.5); }
|
|
2876
|
+
.sk-form h2 { margin: 0 0 4px; font-size: 16px; }
|
|
2877
|
+
.sk-group { display: grid; grid-template-columns: repeat(var(--sk-cols, 1), minmax(0, 1fr)); gap: 12px; border: 0; padding: 0; margin: 0; min-width: 0; }
|
|
2878
|
+
.sk-group + .sk-group { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--sg-border, #e2e8f0); }
|
|
2879
|
+
.sk-group legend { grid-column: 1 / -1; padding: 0; font-size: 12px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; color: var(--sg-muted, #64748b); }
|
|
2880
|
+
.sk-group__desc { grid-column: 1 / -1; margin: 0; font-size: 12px; color: var(--sg-muted, #64748b); }
|
|
2881
|
+
/* A foldable group is a native <details>, so it works with JavaScript off.
|
|
2882
|
+
display:grid on the element itself would lay the <summary> out as a grid
|
|
2883
|
+
item and break the disclosure, so the grid moves to [open] children. */
|
|
2884
|
+
.sk-group--fold { display: block; }
|
|
2885
|
+
.sk-group--fold > summary { grid-column: 1 / -1; margin-bottom: 12px; font-size: 13.5px; font-weight: 650; color: var(--sg-fg, #0f172a); cursor: pointer; list-style-position: inside; }
|
|
2886
|
+
.sk-group--fold[open] { display: grid; }
|
|
2887
|
+
.sk-group--fold[open] > summary { margin-bottom: 0; }
|
|
2888
|
+
.sk-field--wide { grid-column: 1 / -1; }
|
|
2889
|
+
.sk-hint { color: var(--sg-muted, #64748b); font-size: 11.5px; }
|
|
2890
|
+
@media (max-width: 560px) { .sk-group { grid-template-columns: 1fr; } }
|
|
2891
|
+
.sk-field { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
|
|
2892
|
+
.sk-field span { color: var(--sg-muted, #64748b); }
|
|
2893
|
+
.sk-field input, .sk-field select { font: inherit; padding: 7px 9px; border-radius: 8px; border: 1px solid var(--sg-border, #cbd5e1); background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
|
|
2894
|
+
.sk-field input[type='checkbox'] { align-self: flex-start; width: auto; }
|
|
2895
|
+
.sk-err { color: #dc2626; font-size: 12px; font-style: normal; }
|
|
2896
|
+
.sk-form__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; }
|
|
2897
|
+
</style>
|
|
2898
2898
|
`;
|
|
2899
2899
|
return [
|
|
2900
2900
|
{ path: `src/routes/${screen.route}/+page.server.ts`, description: `SSR load + CRUD form actions for ${n.label}.`, contents: server },
|
|
@@ -3089,29 +3089,29 @@ function i18nModule(project) {
|
|
|
3089
3089
|
return {
|
|
3090
3090
|
path: 'src/lib/i18n.ts',
|
|
3091
3091
|
description: 'Localization: locales, the current-locale store, the message catalog, and t() / localizeCols helpers.',
|
|
3092
|
-
contents: `import { writable, derived } from 'svelte/store'
|
|
3093
|
-
|
|
3094
|
-
export type Locale = ${localeUnion}
|
|
3095
|
-
export const locales: Locale[] = ${JSON.stringify(locales)} as Locale[]
|
|
3096
|
-
export const currentLocale = writable<Locale>(${JSON.stringify(def)})
|
|
3097
|
-
|
|
3098
|
-
// Seeded from your schema + screen labels. Every locale starts with the same
|
|
3099
|
-
// keys and the default-locale text, so translating is editing values in place -
|
|
3100
|
-
// no key hunting. Anything you delete falls back to the default locale.
|
|
3101
|
-
const messages: Record<Locale, Record<string, string>> = {
|
|
3102
|
-
${messagesEntries}
|
|
3103
|
-
}
|
|
3104
|
-
|
|
3105
|
-
/** Reactive translator: \`$t('key', 'fallback')\`. Falls back to the default locale, then the fallback, then the key. */
|
|
3106
|
-
export const t = derived(currentLocale, ($l) => (key: string, fallback?: string): string =>
|
|
3107
|
-
messages[$l]?.[key] ?? messages[${JSON.stringify(def)}]?.[key] ?? fallback ?? key)
|
|
3108
|
-
|
|
3109
|
-
/** Localize a column list's headers via \`col.<entity>.<field>\` keys. */
|
|
3110
|
-
export function localizeCols<T extends { field?: string | number; header?: string }>(
|
|
3111
|
-
cols: T[], entity: string, translate: (k: string, fb?: string) => string,
|
|
3112
|
-
): T[] {
|
|
3113
|
-
return cols.map((c) => ({ ...c, header: translate('col.' + entity + '.' + String(c.field), c.header ?? String(c.field ?? '')) }))
|
|
3114
|
-
}
|
|
3092
|
+
contents: `import { writable, derived } from 'svelte/store'
|
|
3093
|
+
|
|
3094
|
+
export type Locale = ${localeUnion}
|
|
3095
|
+
export const locales: Locale[] = ${JSON.stringify(locales)} as Locale[]
|
|
3096
|
+
export const currentLocale = writable<Locale>(${JSON.stringify(def)})
|
|
3097
|
+
|
|
3098
|
+
// Seeded from your schema + screen labels. Every locale starts with the same
|
|
3099
|
+
// keys and the default-locale text, so translating is editing values in place -
|
|
3100
|
+
// no key hunting. Anything you delete falls back to the default locale.
|
|
3101
|
+
const messages: Record<Locale, Record<string, string>> = {
|
|
3102
|
+
${messagesEntries}
|
|
3103
|
+
}
|
|
3104
|
+
|
|
3105
|
+
/** Reactive translator: \`$t('key', 'fallback')\`. Falls back to the default locale, then the fallback, then the key. */
|
|
3106
|
+
export const t = derived(currentLocale, ($l) => (key: string, fallback?: string): string =>
|
|
3107
|
+
messages[$l]?.[key] ?? messages[${JSON.stringify(def)}]?.[key] ?? fallback ?? key)
|
|
3108
|
+
|
|
3109
|
+
/** Localize a column list's headers via \`col.<entity>.<field>\` keys. */
|
|
3110
|
+
export function localizeCols<T extends { field?: string | number; header?: string }>(
|
|
3111
|
+
cols: T[], entity: string, translate: (k: string, fb?: string) => string,
|
|
3112
|
+
): T[] {
|
|
3113
|
+
return cols.map((c) => ({ ...c, header: translate('col.' + entity + '.' + String(c.field), c.header ?? String(c.field ?? '')) }))
|
|
3114
|
+
}
|
|
3115
3115
|
`,
|
|
3116
3116
|
};
|
|
3117
3117
|
}
|
|
@@ -3128,60 +3128,60 @@ function auditModule(persisted = false) {
|
|
|
3128
3128
|
return {
|
|
3129
3129
|
path: 'src/lib/audit.ts',
|
|
3130
3130
|
description: 'Audit trail store: the AuditEntry schema, an in-memory source, and recordAudit(). NOT persisted - enable the typed data layer to write to a real table.',
|
|
3131
|
-
contents: `import { createInMemoryDataSource } from '@svgrid/enterprise'
|
|
3132
|
-
import type { EntitySchema } from '@svgrid/enterprise'
|
|
3133
|
-
|
|
3134
|
-
export type AuditEntry = {
|
|
3135
|
-
id: string
|
|
3136
|
-
at: string
|
|
3137
|
-
actor: string
|
|
3138
|
-
entity: string
|
|
3139
|
-
action: 'create' | 'update' | 'delete'
|
|
3140
|
-
recordId: string
|
|
3141
|
-
summary: string
|
|
3142
|
-
}
|
|
3143
|
-
|
|
3144
|
-
export const auditSchema: EntitySchema<AuditEntry> = {
|
|
3145
|
-
name: 'audit',
|
|
3146
|
-
idField: 'id',
|
|
3147
|
-
fields: [
|
|
3148
|
-
{ field: 'id', type: 'text', primaryKey: true, readonly: true },
|
|
3149
|
-
{ field: 'at', type: 'datetime', label: 'When' },
|
|
3150
|
-
{ field: 'actor', type: 'text', label: 'Actor' },
|
|
3151
|
-
{ field: 'entity', type: 'text', label: 'Entity' },
|
|
3152
|
-
{ field: 'action', type: 'enum', label: 'Action', options: [{ value: 'create', label: 'Create' }, { value: 'update', label: 'Update' }, { value: 'delete', label: 'Delete' }] },
|
|
3153
|
-
{ field: 'recordId', type: 'text', label: 'Record' },
|
|
3154
|
-
{ field: 'summary', type: 'text', label: 'Summary' },
|
|
3155
|
-
],
|
|
3156
|
-
}
|
|
3157
|
-
|
|
3158
|
-
// In-memory, server-side singleton. Replace with a SQL / Supabase source to
|
|
3159
|
-
// persist the trail across restarts (recordAudit + the /audit viewer keep working).
|
|
3160
|
-
export const auditSource = createInMemoryDataSource<AuditEntry>([], auditSchema)
|
|
3161
|
-
let seq = 0
|
|
3162
|
-
|
|
3163
|
-
/** Append one change record. Called by the API routes' \`audit\` hook. */
|
|
3164
|
-
export async function recordAudit(input: {
|
|
3165
|
-
entity: string
|
|
3166
|
-
action: 'create' | 'update' | 'delete'
|
|
3167
|
-
recordId: string | null
|
|
3168
|
-
values?: Record<string, unknown>
|
|
3169
|
-
actor?: string
|
|
3170
|
-
}): Promise<void> {
|
|
3171
|
-
const summary =
|
|
3172
|
-
input.action === 'delete'
|
|
3173
|
-
? \`Deleted \${input.entity} \${input.recordId ?? ''}\`.trim()
|
|
3174
|
-
: \`\${input.action === 'create' ? 'Created' : 'Updated'} \${input.entity}\${input.values ? ' (' + Object.keys(input.values).join(', ') + ')' : ''}\`
|
|
3175
|
-
await auditSource.createRow?.({
|
|
3176
|
-
id: String(++seq),
|
|
3177
|
-
at: new Date().toISOString(),
|
|
3178
|
-
actor: input.actor ?? 'system',
|
|
3179
|
-
entity: input.entity,
|
|
3180
|
-
action: input.action,
|
|
3181
|
-
recordId: input.recordId ?? '',
|
|
3182
|
-
summary,
|
|
3183
|
-
})
|
|
3184
|
-
}
|
|
3131
|
+
contents: `import { createInMemoryDataSource } from '@svgrid/enterprise'
|
|
3132
|
+
import type { EntitySchema } from '@svgrid/enterprise'
|
|
3133
|
+
|
|
3134
|
+
export type AuditEntry = {
|
|
3135
|
+
id: string
|
|
3136
|
+
at: string
|
|
3137
|
+
actor: string
|
|
3138
|
+
entity: string
|
|
3139
|
+
action: 'create' | 'update' | 'delete'
|
|
3140
|
+
recordId: string
|
|
3141
|
+
summary: string
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
export const auditSchema: EntitySchema<AuditEntry> = {
|
|
3145
|
+
name: 'audit',
|
|
3146
|
+
idField: 'id',
|
|
3147
|
+
fields: [
|
|
3148
|
+
{ field: 'id', type: 'text', primaryKey: true, readonly: true },
|
|
3149
|
+
{ field: 'at', type: 'datetime', label: 'When' },
|
|
3150
|
+
{ field: 'actor', type: 'text', label: 'Actor' },
|
|
3151
|
+
{ field: 'entity', type: 'text', label: 'Entity' },
|
|
3152
|
+
{ field: 'action', type: 'enum', label: 'Action', options: [{ value: 'create', label: 'Create' }, { value: 'update', label: 'Update' }, { value: 'delete', label: 'Delete' }] },
|
|
3153
|
+
{ field: 'recordId', type: 'text', label: 'Record' },
|
|
3154
|
+
{ field: 'summary', type: 'text', label: 'Summary' },
|
|
3155
|
+
],
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
// In-memory, server-side singleton. Replace with a SQL / Supabase source to
|
|
3159
|
+
// persist the trail across restarts (recordAudit + the /audit viewer keep working).
|
|
3160
|
+
export const auditSource = createInMemoryDataSource<AuditEntry>([], auditSchema)
|
|
3161
|
+
let seq = 0
|
|
3162
|
+
|
|
3163
|
+
/** Append one change record. Called by the API routes' \`audit\` hook. */
|
|
3164
|
+
export async function recordAudit(input: {
|
|
3165
|
+
entity: string
|
|
3166
|
+
action: 'create' | 'update' | 'delete'
|
|
3167
|
+
recordId: string | null
|
|
3168
|
+
values?: Record<string, unknown>
|
|
3169
|
+
actor?: string
|
|
3170
|
+
}): Promise<void> {
|
|
3171
|
+
const summary =
|
|
3172
|
+
input.action === 'delete'
|
|
3173
|
+
? \`Deleted \${input.entity} \${input.recordId ?? ''}\`.trim()
|
|
3174
|
+
: \`\${input.action === 'create' ? 'Created' : 'Updated'} \${input.entity}\${input.values ? ' (' + Object.keys(input.values).join(', ') + ')' : ''}\`
|
|
3175
|
+
await auditSource.createRow?.({
|
|
3176
|
+
id: String(++seq),
|
|
3177
|
+
at: new Date().toISOString(),
|
|
3178
|
+
actor: input.actor ?? 'system',
|
|
3179
|
+
entity: input.entity,
|
|
3180
|
+
action: input.action,
|
|
3181
|
+
recordId: input.recordId ?? '',
|
|
3182
|
+
summary,
|
|
3183
|
+
})
|
|
3184
|
+
}
|
|
3185
3185
|
`,
|
|
3186
3186
|
};
|
|
3187
3187
|
}
|
|
@@ -3192,92 +3192,92 @@ function auditModulePersisted() {
|
|
|
3192
3192
|
return {
|
|
3193
3193
|
path: 'src/lib/audit.ts',
|
|
3194
3194
|
description: 'Audit trail: the AuditEntry schema, a Drizzle-backed source over audit_log, and recordAudit() with before/after snapshots.',
|
|
3195
|
-
contents: `import { count, desc } from 'drizzle-orm'
|
|
3196
|
-
import type { EntitySchema } from '@svgrid/enterprise'
|
|
3197
|
-
import { db } from '../lib/server/db'
|
|
3198
|
-
import { auditLog } from '../lib/server/db/schema'
|
|
3199
|
-
|
|
3200
|
-
export type AuditEntry = {
|
|
3201
|
-
id: string
|
|
3202
|
-
at: string
|
|
3203
|
-
actor: string
|
|
3204
|
-
entity: string
|
|
3205
|
-
action: 'create' | 'update' | 'delete'
|
|
3206
|
-
recordId: string
|
|
3207
|
-
summary: string
|
|
3208
|
-
/** JSON snapshot of the row before the change (update / delete). */
|
|
3209
|
-
before?: string | null
|
|
3210
|
-
/** JSON snapshot of the row after it (create / update). */
|
|
3211
|
-
after?: string | null
|
|
3212
|
-
}
|
|
3213
|
-
|
|
3214
|
-
export const auditSchema: EntitySchema<AuditEntry> = {
|
|
3215
|
-
name: 'audit',
|
|
3216
|
-
idField: 'id',
|
|
3217
|
-
fields: [
|
|
3218
|
-
{ field: 'id', type: 'text', primaryKey: true, readonly: true },
|
|
3219
|
-
{ field: 'at', type: 'datetime', label: 'When' },
|
|
3220
|
-
{ field: 'actor', type: 'text', label: 'Actor' },
|
|
3221
|
-
{ field: 'entity', type: 'text', label: 'Entity' },
|
|
3222
|
-
{ field: 'action', type: 'enum', label: 'Action', options: [{ value: 'create', label: 'Create' }, { value: 'update', label: 'Update' }, { value: 'delete', label: 'Delete' }] },
|
|
3223
|
-
{ field: 'recordId', type: 'text', label: 'Record' },
|
|
3224
|
-
{ field: 'summary', type: 'text', label: 'Summary' },
|
|
3225
|
-
{ field: 'before', type: 'json', label: 'Before', readonly: true },
|
|
3226
|
-
{ field: 'after', type: 'json', label: 'After', readonly: true },
|
|
3227
|
-
],
|
|
3228
|
-
}
|
|
3229
|
-
|
|
3230
|
-
const toEntry = (r: typeof auditLog.$inferSelect): AuditEntry => ({
|
|
3231
|
-
id: String(r.id),
|
|
3232
|
-
at: typeof r.at === 'string' ? r.at : new Date(r.at as unknown as Date).toISOString(),
|
|
3233
|
-
actor: r.actor,
|
|
3234
|
-
entity: r.entity,
|
|
3235
|
-
action: r.action as AuditEntry['action'],
|
|
3236
|
-
recordId: r.recordId,
|
|
3237
|
-
summary: r.summary,
|
|
3238
|
-
before: r.before ?? null,
|
|
3239
|
-
after: r.after ?? null,
|
|
3240
|
-
})
|
|
3241
|
-
|
|
3242
|
-
/** Read-only \\\`ServerDataSource\\\` for the /audit viewer. Newest first, paged in
|
|
3243
|
-
* the database so a long trail doesn't load in one go. No write methods: the
|
|
3244
|
-
* trail is append-only through \\\`recordAudit\\\`. */
|
|
3245
|
-
export const auditSource = {
|
|
3246
|
-
async getRows(request: { startRow?: number; endRow?: number }) {
|
|
3247
|
-
const start = request.startRow ?? 0
|
|
3248
|
-
const limit = Math.max(1, (request.endRow ?? start + 25) - start)
|
|
3249
|
-
const [rows, counted] = await Promise.all([
|
|
3250
|
-
db.select().from(auditLog).orderBy(desc(auditLog.at)).limit(limit).offset(start),
|
|
3251
|
-
db.select({ n: count() }).from(auditLog),
|
|
3252
|
-
])
|
|
3253
|
-
return { rows: rows.map(toEntry), rowCount: Number(counted.at(0)?.n ?? 0) }
|
|
3254
|
-
},
|
|
3255
|
-
}
|
|
3256
|
-
|
|
3257
|
-
/** Append one change record. Called by the API routes' \\\`audit\\\` hook. */
|
|
3258
|
-
export async function recordAudit(input: {
|
|
3259
|
-
entity: string
|
|
3260
|
-
action: 'create' | 'update' | 'delete'
|
|
3261
|
-
recordId: string | null
|
|
3262
|
-
values?: Record<string, unknown>
|
|
3263
|
-
before?: Record<string, unknown> | null
|
|
3264
|
-
actor?: string
|
|
3265
|
-
}): Promise<void> {
|
|
3266
|
-
const summary =
|
|
3267
|
-
input.action === 'delete'
|
|
3268
|
-
? \`Deleted \${input.entity} \${input.recordId ?? ''}\`.trim()
|
|
3269
|
-
: \`\${input.action === 'create' ? 'Created' : 'Updated'} \${input.entity}\${input.values ? ' (' + Object.keys(input.values).join(', ') + ')' : ''}\`
|
|
3270
|
-
await db.insert(auditLog).values({
|
|
3271
|
-
at: new Date().toISOString(),
|
|
3272
|
-
actor: input.actor ?? 'system',
|
|
3273
|
-
entity: input.entity,
|
|
3274
|
-
action: input.action,
|
|
3275
|
-
recordId: input.recordId ?? '',
|
|
3276
|
-
summary,
|
|
3277
|
-
before: input.before ? JSON.stringify(input.before) : null,
|
|
3278
|
-
after: input.values ? JSON.stringify(input.values) : null,
|
|
3279
|
-
} as typeof auditLog.$inferInsert)
|
|
3280
|
-
}
|
|
3195
|
+
contents: `import { count, desc } from 'drizzle-orm'
|
|
3196
|
+
import type { EntitySchema } from '@svgrid/enterprise'
|
|
3197
|
+
import { db } from '../lib/server/db'
|
|
3198
|
+
import { auditLog } from '../lib/server/db/schema'
|
|
3199
|
+
|
|
3200
|
+
export type AuditEntry = {
|
|
3201
|
+
id: string
|
|
3202
|
+
at: string
|
|
3203
|
+
actor: string
|
|
3204
|
+
entity: string
|
|
3205
|
+
action: 'create' | 'update' | 'delete'
|
|
3206
|
+
recordId: string
|
|
3207
|
+
summary: string
|
|
3208
|
+
/** JSON snapshot of the row before the change (update / delete). */
|
|
3209
|
+
before?: string | null
|
|
3210
|
+
/** JSON snapshot of the row after it (create / update). */
|
|
3211
|
+
after?: string | null
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
export const auditSchema: EntitySchema<AuditEntry> = {
|
|
3215
|
+
name: 'audit',
|
|
3216
|
+
idField: 'id',
|
|
3217
|
+
fields: [
|
|
3218
|
+
{ field: 'id', type: 'text', primaryKey: true, readonly: true },
|
|
3219
|
+
{ field: 'at', type: 'datetime', label: 'When' },
|
|
3220
|
+
{ field: 'actor', type: 'text', label: 'Actor' },
|
|
3221
|
+
{ field: 'entity', type: 'text', label: 'Entity' },
|
|
3222
|
+
{ field: 'action', type: 'enum', label: 'Action', options: [{ value: 'create', label: 'Create' }, { value: 'update', label: 'Update' }, { value: 'delete', label: 'Delete' }] },
|
|
3223
|
+
{ field: 'recordId', type: 'text', label: 'Record' },
|
|
3224
|
+
{ field: 'summary', type: 'text', label: 'Summary' },
|
|
3225
|
+
{ field: 'before', type: 'json', label: 'Before', readonly: true },
|
|
3226
|
+
{ field: 'after', type: 'json', label: 'After', readonly: true },
|
|
3227
|
+
],
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
const toEntry = (r: typeof auditLog.$inferSelect): AuditEntry => ({
|
|
3231
|
+
id: String(r.id),
|
|
3232
|
+
at: typeof r.at === 'string' ? r.at : new Date(r.at as unknown as Date).toISOString(),
|
|
3233
|
+
actor: r.actor,
|
|
3234
|
+
entity: r.entity,
|
|
3235
|
+
action: r.action as AuditEntry['action'],
|
|
3236
|
+
recordId: r.recordId,
|
|
3237
|
+
summary: r.summary,
|
|
3238
|
+
before: r.before ?? null,
|
|
3239
|
+
after: r.after ?? null,
|
|
3240
|
+
})
|
|
3241
|
+
|
|
3242
|
+
/** Read-only \\\`ServerDataSource\\\` for the /audit viewer. Newest first, paged in
|
|
3243
|
+
* the database so a long trail doesn't load in one go. No write methods: the
|
|
3244
|
+
* trail is append-only through \\\`recordAudit\\\`. */
|
|
3245
|
+
export const auditSource = {
|
|
3246
|
+
async getRows(request: { startRow?: number; endRow?: number }) {
|
|
3247
|
+
const start = request.startRow ?? 0
|
|
3248
|
+
const limit = Math.max(1, (request.endRow ?? start + 25) - start)
|
|
3249
|
+
const [rows, counted] = await Promise.all([
|
|
3250
|
+
db.select().from(auditLog).orderBy(desc(auditLog.at)).limit(limit).offset(start),
|
|
3251
|
+
db.select({ n: count() }).from(auditLog),
|
|
3252
|
+
])
|
|
3253
|
+
return { rows: rows.map(toEntry), rowCount: Number(counted.at(0)?.n ?? 0) }
|
|
3254
|
+
},
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
/** Append one change record. Called by the API routes' \\\`audit\\\` hook. */
|
|
3258
|
+
export async function recordAudit(input: {
|
|
3259
|
+
entity: string
|
|
3260
|
+
action: 'create' | 'update' | 'delete'
|
|
3261
|
+
recordId: string | null
|
|
3262
|
+
values?: Record<string, unknown>
|
|
3263
|
+
before?: Record<string, unknown> | null
|
|
3264
|
+
actor?: string
|
|
3265
|
+
}): Promise<void> {
|
|
3266
|
+
const summary =
|
|
3267
|
+
input.action === 'delete'
|
|
3268
|
+
? \`Deleted \${input.entity} \${input.recordId ?? ''}\`.trim()
|
|
3269
|
+
: \`\${input.action === 'create' ? 'Created' : 'Updated'} \${input.entity}\${input.values ? ' (' + Object.keys(input.values).join(', ') + ')' : ''}\`
|
|
3270
|
+
await db.insert(auditLog).values({
|
|
3271
|
+
at: new Date().toISOString(),
|
|
3272
|
+
actor: input.actor ?? 'system',
|
|
3273
|
+
entity: input.entity,
|
|
3274
|
+
action: input.action,
|
|
3275
|
+
recordId: input.recordId ?? '',
|
|
3276
|
+
summary,
|
|
3277
|
+
before: input.before ? JSON.stringify(input.before) : null,
|
|
3278
|
+
after: input.values ? JSON.stringify(input.values) : null,
|
|
3279
|
+
} as typeof auditLog.$inferInsert)
|
|
3280
|
+
}
|
|
3281
3281
|
`,
|
|
3282
3282
|
};
|
|
3283
3283
|
}
|
|
@@ -3292,31 +3292,31 @@ function tenantModule(field) {
|
|
|
3292
3292
|
return {
|
|
3293
3293
|
path: 'src/lib/server/tenant.ts',
|
|
3294
3294
|
description: 'Resolve the caller\'s tenant from the session; used to scope every API route.',
|
|
3295
|
-
contents: `// Regenerated by SvGrid Studio. Multi-tenancy: which tenant is calling?
|
|
3296
|
-
//
|
|
3297
|
-
// The tenant is carried on the session (see hooks.server.ts / auth.ts) and
|
|
3298
|
-
// stamped onto \`event.locals\`. Every scoped API route calls requireTenant().
|
|
3299
|
-
|
|
3300
|
-
export type TenantEvent = { locals?: Record<string, unknown> }
|
|
3301
|
-
|
|
3302
|
-
/** The caller's tenant id, or null when there is none. */
|
|
3303
|
-
export function getTenant(event: TenantEvent): string | null {
|
|
3304
|
-
const t = event.locals?.${field}
|
|
3305
|
-
return t == null || t === '' ? null : String(t)
|
|
3306
|
-
}
|
|
3307
|
-
|
|
3308
|
-
/**
|
|
3309
|
-
* The caller's tenant id, or THROW.
|
|
3310
|
-
*
|
|
3311
|
-
* Throwing is the point: the route's \`scope\` turns it into a 403. Returning
|
|
3312
|
-
* null here would let the query run unscoped, which is the one failure mode
|
|
3313
|
-
* multi-tenancy cannot have.
|
|
3314
|
-
*/
|
|
3315
|
-
export function requireTenant(event: TenantEvent): string {
|
|
3316
|
-
const t = getTenant(event)
|
|
3317
|
-
if (!t) throw new Error('No tenant on the session - sign in again.')
|
|
3318
|
-
return t
|
|
3319
|
-
}
|
|
3295
|
+
contents: `// Regenerated by SvGrid Studio. Multi-tenancy: which tenant is calling?
|
|
3296
|
+
//
|
|
3297
|
+
// The tenant is carried on the session (see hooks.server.ts / auth.ts) and
|
|
3298
|
+
// stamped onto \`event.locals\`. Every scoped API route calls requireTenant().
|
|
3299
|
+
|
|
3300
|
+
export type TenantEvent = { locals?: Record<string, unknown> }
|
|
3301
|
+
|
|
3302
|
+
/** The caller's tenant id, or null when there is none. */
|
|
3303
|
+
export function getTenant(event: TenantEvent): string | null {
|
|
3304
|
+
const t = event.locals?.${field}
|
|
3305
|
+
return t == null || t === '' ? null : String(t)
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
/**
|
|
3309
|
+
* The caller's tenant id, or THROW.
|
|
3310
|
+
*
|
|
3311
|
+
* Throwing is the point: the route's \`scope\` turns it into a 403. Returning
|
|
3312
|
+
* null here would let the query run unscoped, which is the one failure mode
|
|
3313
|
+
* multi-tenancy cannot have.
|
|
3314
|
+
*/
|
|
3315
|
+
export function requireTenant(event: TenantEvent): string {
|
|
3316
|
+
const t = getTenant(event)
|
|
3317
|
+
if (!t) throw new Error('No tenant on the session - sign in again.')
|
|
3318
|
+
return t
|
|
3319
|
+
}
|
|
3320
3320
|
`,
|
|
3321
3321
|
};
|
|
3322
3322
|
}
|
|
@@ -3341,10 +3341,10 @@ function jobsFiles(project, jobs, emailAvailable) {
|
|
|
3341
3341
|
return ` ${JSON.stringify(j.id)}: async () => {\n // ${label}: cannot send - ${why}.\n console.warn('cron ${j.id}: skipped (${why})')\n },`;
|
|
3342
3342
|
}
|
|
3343
3343
|
const n = namesFor(ent);
|
|
3344
|
-
return ` ${JSON.stringify(j.id)}: async () => {
|
|
3345
|
-
const { rows, rowCount } = await ${n.sourceVar}.getRows({ startRow: 0, endRow: 10, sortModel: [], filterModel: {} })
|
|
3346
|
-
const items = rows.map((r) => '<li>' + Object.values(r).slice(0, 3).map(String).join(' · ') + '</li>').join('')
|
|
3347
|
-
await sendEmail(${to}, ${subject}, '<p>' + rowCount + ' ${ent.name} total. Most recent:</p><ul>' + items + '</ul>')
|
|
3344
|
+
return ` ${JSON.stringify(j.id)}: async () => {
|
|
3345
|
+
const { rows, rowCount } = await ${n.sourceVar}.getRows({ startRow: 0, endRow: 10, sortModel: [], filterModel: {} })
|
|
3346
|
+
const items = rows.map((r) => '<li>' + Object.values(r).slice(0, 3).map(String).join(' · ') + '</li>').join('')
|
|
3347
|
+
await sendEmail(${to}, ${subject}, '<p>' + rowCount + ' ${ent.name} total. Most recent:</p><ul>' + items + '</ul>')
|
|
3348
3348
|
},`;
|
|
3349
3349
|
}
|
|
3350
3350
|
const body = (j.code ?? '').trim() || `console.log('cron ${j.id}: no body yet')`;
|
|
@@ -3363,59 +3363,59 @@ function jobsFiles(project, jobs, emailAvailable) {
|
|
|
3363
3363
|
...(jobs.some((j) => j.kind === 'email') && emailAvailable ? [`import { sendEmail } from '../lib/server/email'`] : []),
|
|
3364
3364
|
];
|
|
3365
3365
|
const table = jobs.map((j) => ` * ${j.id.padEnd(20)} ${j.cron.padEnd(16)} ${j.name}`).join('\n');
|
|
3366
|
-
const jobsTs = `// Regenerated by SvGrid Studio. Scheduled job handlers.
|
|
3367
|
-
//
|
|
3368
|
-
// Schedule (UTC):
|
|
3369
|
-
${table}
|
|
3370
|
-
//
|
|
3371
|
-
// Runs on the server, triggered by /api/cron - NOT in the browser. Edit a
|
|
3372
|
-
// handler body freely; the registry keys are what /api/cron dispatches on.
|
|
3373
|
-
${imports.join('\n')}
|
|
3374
|
-
|
|
3375
|
-
export const jobs: Record<string, () => Promise<void>> = {
|
|
3376
|
-
${jobs.map(handler).join('\n')}
|
|
3377
|
-
}
|
|
3378
|
-
|
|
3379
|
-
/** Job ids that are switched on. /api/cron without ?job= runs exactly these. */
|
|
3380
|
-
export const enabledJobs: string[] = ${JSON.stringify(jobs.filter((j) => j.enabled !== false).map((j) => j.id))}
|
|
3366
|
+
const jobsTs = `// Regenerated by SvGrid Studio. Scheduled job handlers.
|
|
3367
|
+
//
|
|
3368
|
+
// Schedule (UTC):
|
|
3369
|
+
${table}
|
|
3370
|
+
//
|
|
3371
|
+
// Runs on the server, triggered by /api/cron - NOT in the browser. Edit a
|
|
3372
|
+
// handler body freely; the registry keys are what /api/cron dispatches on.
|
|
3373
|
+
${imports.join('\n')}
|
|
3374
|
+
|
|
3375
|
+
export const jobs: Record<string, () => Promise<void>> = {
|
|
3376
|
+
${jobs.map(handler).join('\n')}
|
|
3377
|
+
}
|
|
3378
|
+
|
|
3379
|
+
/** Job ids that are switched on. /api/cron without ?job= runs exactly these. */
|
|
3380
|
+
export const enabledJobs: string[] = ${JSON.stringify(jobs.filter((j) => j.enabled !== false).map((j) => j.id))}
|
|
3381
3381
|
`;
|
|
3382
|
-
const routeTs = `import { json, error } from '@sveltejs/kit'
|
|
3383
|
-
import { env } from '$env/dynamic/private'
|
|
3384
|
-
import type { RequestHandler } from './$types'
|
|
3385
|
-
import { jobs, enabledJobs } from '../lib/server/jobs'
|
|
3386
|
-
|
|
3387
|
-
/**
|
|
3388
|
-
* Scheduled-job endpoint. Your platform's scheduler calls this (Vercel Cron, a
|
|
3389
|
-
* GitHub Actions schedule, or a crontab running \`curl\`). See DEPLOY.md.
|
|
3390
|
-
*
|
|
3391
|
-
* Guarded by CRON_SECRET: send it as \`Authorization: Bearer <secret>\` or
|
|
3392
|
-
* \`?secret=<secret>\`. With no CRON_SECRET set the route refuses to run rather
|
|
3393
|
-
* than leaving a public "do work" URL open.
|
|
3394
|
-
*/
|
|
3395
|
-
const authorized = (request: Request, url: URL): boolean => {
|
|
3396
|
-
const secret = env.CRON_SECRET
|
|
3397
|
-
if (!secret) return false
|
|
3398
|
-
const header = request.headers.get('authorization')
|
|
3399
|
-
return header === 'Bearer ' + secret || url.searchParams.get('secret') === secret
|
|
3400
|
-
}
|
|
3401
|
-
|
|
3402
|
-
const run: RequestHandler = async ({ request, url }) => {
|
|
3403
|
-
if (!authorized(request, url)) throw error(401, 'cron: missing or invalid CRON_SECRET')
|
|
3404
|
-
const only = url.searchParams.get('job')
|
|
3405
|
-
const ids = only ? [only] : enabledJobs
|
|
3406
|
-
const results: Array<{ job: string; ok: boolean; error?: string }> = []
|
|
3407
|
-
for (const id of ids) {
|
|
3408
|
-
const fn = jobs[id]
|
|
3409
|
-
if (!fn) { results.push({ job: id, ok: false, error: 'unknown job' }); continue }
|
|
3410
|
-
// One failing job must not stop the rest of the run.
|
|
3411
|
-
try { await fn(); results.push({ job: id, ok: true }) }
|
|
3412
|
-
catch (err) { results.push({ job: id, ok: false, error: err instanceof Error ? err.message : String(err) }) }
|
|
3413
|
-
}
|
|
3414
|
-
return json({ ran: results.length, results })
|
|
3415
|
-
}
|
|
3416
|
-
|
|
3417
|
-
export const GET = run
|
|
3418
|
-
export const POST = run
|
|
3382
|
+
const routeTs = `import { json, error } from '@sveltejs/kit'
|
|
3383
|
+
import { env } from '$env/dynamic/private'
|
|
3384
|
+
import type { RequestHandler } from './$types'
|
|
3385
|
+
import { jobs, enabledJobs } from '../lib/server/jobs'
|
|
3386
|
+
|
|
3387
|
+
/**
|
|
3388
|
+
* Scheduled-job endpoint. Your platform's scheduler calls this (Vercel Cron, a
|
|
3389
|
+
* GitHub Actions schedule, or a crontab running \`curl\`). See DEPLOY.md.
|
|
3390
|
+
*
|
|
3391
|
+
* Guarded by CRON_SECRET: send it as \`Authorization: Bearer <secret>\` or
|
|
3392
|
+
* \`?secret=<secret>\`. With no CRON_SECRET set the route refuses to run rather
|
|
3393
|
+
* than leaving a public "do work" URL open.
|
|
3394
|
+
*/
|
|
3395
|
+
const authorized = (request: Request, url: URL): boolean => {
|
|
3396
|
+
const secret = env.CRON_SECRET
|
|
3397
|
+
if (!secret) return false
|
|
3398
|
+
const header = request.headers.get('authorization')
|
|
3399
|
+
return header === 'Bearer ' + secret || url.searchParams.get('secret') === secret
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
const run: RequestHandler = async ({ request, url }) => {
|
|
3403
|
+
if (!authorized(request, url)) throw error(401, 'cron: missing or invalid CRON_SECRET')
|
|
3404
|
+
const only = url.searchParams.get('job')
|
|
3405
|
+
const ids = only ? [only] : enabledJobs
|
|
3406
|
+
const results: Array<{ job: string; ok: boolean; error?: string }> = []
|
|
3407
|
+
for (const id of ids) {
|
|
3408
|
+
const fn = jobs[id]
|
|
3409
|
+
if (!fn) { results.push({ job: id, ok: false, error: 'unknown job' }); continue }
|
|
3410
|
+
// One failing job must not stop the rest of the run.
|
|
3411
|
+
try { await fn(); results.push({ job: id, ok: true }) }
|
|
3412
|
+
catch (err) { results.push({ job: id, ok: false, error: err instanceof Error ? err.message : String(err) }) }
|
|
3413
|
+
}
|
|
3414
|
+
return json({ ran: results.length, results })
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
export const GET = run
|
|
3418
|
+
export const POST = run
|
|
3419
3419
|
`;
|
|
3420
3420
|
return [
|
|
3421
3421
|
{ path: 'src/lib/server/jobs.ts', description: 'Scheduled job handlers, keyed by job id.', contents: jobsTs },
|
|
@@ -3427,10 +3427,10 @@ function auditRouteFile() {
|
|
|
3427
3427
|
return {
|
|
3428
3428
|
path: 'src/routes/api/audit/+server.ts',
|
|
3429
3429
|
description: 'API route for the audit trail (read-only viewer feed).',
|
|
3430
|
-
contents: `import { createKitHandlers } from '@svgrid/enterprise'
|
|
3431
|
-
import { auditSchema, auditSource } from '../lib/audit'
|
|
3432
|
-
|
|
3433
|
-
export const { POST } = createKitHandlers({ schema: auditSchema, source: auditSource })
|
|
3430
|
+
contents: `import { createKitHandlers } from '@svgrid/enterprise'
|
|
3431
|
+
import { auditSchema, auditSource } from '../lib/audit'
|
|
3432
|
+
|
|
3433
|
+
export const { POST } = createKitHandlers({ schema: auditSchema, source: auditSource })
|
|
3434
3434
|
`,
|
|
3435
3435
|
};
|
|
3436
3436
|
}
|
|
@@ -3439,40 +3439,40 @@ function auditViewerPage() {
|
|
|
3439
3439
|
return {
|
|
3440
3440
|
path: 'src/routes/audit/+page.svelte',
|
|
3441
3441
|
description: 'Audit log viewer (read-only grid of change records).',
|
|
3442
|
-
contents: `<script lang="ts">
|
|
3443
|
-
import { SvGrid, createServerDataSource, type ServerState } from '@svgrid/grid'
|
|
3444
|
-
import { schemaToColumns, createKitDataSource } from '@svgrid/enterprise'
|
|
3445
|
-
import { auditSchema, type AuditEntry } from '../lib/audit'
|
|
3446
|
-
|
|
3447
|
-
const source = createKitDataSource<AuditEntry>({ endpoint: '/api/audit' })
|
|
3448
|
-
let view = $state<ServerState<AuditEntry>>({ rows: [], total: 0, loading: false, saving: false, error: null, pageIndex: 0, pageSize: 25, pageCount: 1, sortModel: [], filterModel: {} })
|
|
3449
|
-
const controller = createServerDataSource<AuditEntry>(source, { pageSize: 25, onChange: (s) => (view = s) })
|
|
3450
|
-
$effect(() => { controller.refresh(); return () => controller.dispose() })
|
|
3451
|
-
const columns = schemaToColumns(auditSchema)
|
|
3452
|
-
</script>
|
|
3453
|
-
|
|
3454
|
-
<h1 class="st__title">Audit log</h1>
|
|
3455
|
-
<p class="st__sub">Every create, update, and delete recorded server-side.</p>
|
|
3456
|
-
|
|
3457
|
-
<div class="screen" style="margin-top: 16px">
|
|
3458
|
-
<SvGrid
|
|
3459
|
-
data={view.rows}
|
|
3460
|
-
columns={columns}
|
|
3461
|
-
loading={view.loading}
|
|
3462
|
-
loadingOverlay
|
|
3463
|
-
fitColumns
|
|
3464
|
-
sortable
|
|
3465
|
-
externalSort
|
|
3466
|
-
onSortingChange={(s) => controller.setSort(s)}
|
|
3467
|
-
showPagination
|
|
3468
|
-
externalPagination
|
|
3469
|
-
rowCount={view.total}
|
|
3470
|
-
pageIndex={view.pageIndex}
|
|
3471
|
-
pageSize={view.pageSize}
|
|
3472
|
-
onPaginationChange={({ pageIndex, pageSize }) => (pageSize !== view.pageSize ? controller.setPageSize(pageSize) : controller.setPage(pageIndex))}
|
|
3473
|
-
containerHeight={520}
|
|
3474
|
-
/>
|
|
3475
|
-
</div>
|
|
3442
|
+
contents: `<script lang="ts">
|
|
3443
|
+
import { SvGrid, createServerDataSource, type ServerState } from '@svgrid/grid'
|
|
3444
|
+
import { schemaToColumns, createKitDataSource } from '@svgrid/enterprise'
|
|
3445
|
+
import { auditSchema, type AuditEntry } from '../lib/audit'
|
|
3446
|
+
|
|
3447
|
+
const source = createKitDataSource<AuditEntry>({ endpoint: '/api/audit' })
|
|
3448
|
+
let view = $state<ServerState<AuditEntry>>({ rows: [], total: 0, loading: false, saving: false, error: null, pageIndex: 0, pageSize: 25, pageCount: 1, sortModel: [], filterModel: {} })
|
|
3449
|
+
const controller = createServerDataSource<AuditEntry>(source, { pageSize: 25, onChange: (s) => (view = s) })
|
|
3450
|
+
$effect(() => { controller.refresh(); return () => controller.dispose() })
|
|
3451
|
+
const columns = schemaToColumns(auditSchema)
|
|
3452
|
+
</script>
|
|
3453
|
+
|
|
3454
|
+
<h1 class="st__title">Audit log</h1>
|
|
3455
|
+
<p class="st__sub">Every create, update, and delete recorded server-side.</p>
|
|
3456
|
+
|
|
3457
|
+
<div class="screen" style="margin-top: 16px">
|
|
3458
|
+
<SvGrid
|
|
3459
|
+
data={view.rows}
|
|
3460
|
+
columns={columns}
|
|
3461
|
+
loading={view.loading}
|
|
3462
|
+
loadingOverlay
|
|
3463
|
+
fitColumns
|
|
3464
|
+
sortable
|
|
3465
|
+
externalSort
|
|
3466
|
+
onSortingChange={(s) => controller.setSort(s)}
|
|
3467
|
+
showPagination
|
|
3468
|
+
externalPagination
|
|
3469
|
+
rowCount={view.total}
|
|
3470
|
+
pageIndex={view.pageIndex}
|
|
3471
|
+
pageSize={view.pageSize}
|
|
3472
|
+
onPaginationChange={({ pageIndex, pageSize }) => (pageSize !== view.pageSize ? controller.setPageSize(pageSize) : controller.setPage(pageIndex))}
|
|
3473
|
+
containerHeight={520}
|
|
3474
|
+
/>
|
|
3475
|
+
</div>
|
|
3476
3476
|
`,
|
|
3477
3477
|
};
|
|
3478
3478
|
}
|
|
@@ -3488,51 +3488,51 @@ function accessModule(project) {
|
|
|
3488
3488
|
return {
|
|
3489
3489
|
path: 'src/lib/access.ts',
|
|
3490
3490
|
description: 'RBAC policy: roles, screen + action permissions, the current-role store, and server helpers. Shared by the UI and the API routes.',
|
|
3491
|
-
contents: `import { writable } from 'svelte/store'
|
|
3492
|
-
|
|
3493
|
-
export type AppRole = ${roleUnion}
|
|
3494
|
-
export type WriteAction = 'create' | 'update' | 'delete'
|
|
3495
|
-
export const ROLES: AppRole[] = ${JSON.stringify(roleNames)} as AppRole[]
|
|
3496
|
-
|
|
3497
|
-
const SCREENS: Record<AppRole, '*' | string[]> = {
|
|
3498
|
-
${screensEntries}
|
|
3499
|
-
}
|
|
3500
|
-
const ACTIONS: Record<AppRole, '*' | WriteAction[]> = {
|
|
3501
|
-
${actionsEntries}
|
|
3502
|
-
}
|
|
3503
|
-
|
|
3504
|
-
/** The signed-in user's role. Set it after login (e.g. from the session);
|
|
3505
|
-
* defaults to the project's default role. Read it in components as \`$currentRole\`. */
|
|
3506
|
-
export const currentRole = writable<AppRole>(${JSON.stringify(defaultRole)})
|
|
3507
|
-
|
|
3508
|
-
/** May this role open the given screen id? */
|
|
3509
|
-
export function canScreen(role: AppRole, screenId: string): boolean {
|
|
3510
|
-
const s = SCREENS[role]
|
|
3511
|
-
return s === '*' || (Array.isArray(s) && s.includes(screenId))
|
|
3512
|
-
}
|
|
3513
|
-
/** May this role perform a write action? (Reads are implied by screen access.) */
|
|
3514
|
-
export function can(role: AppRole, action: WriteAction): boolean {
|
|
3515
|
-
const a = ACTIONS[role]
|
|
3516
|
-
return a === '*' || (Array.isArray(a) && a.includes(action))
|
|
3517
|
-
}
|
|
3518
|
-
|
|
3519
|
-
// ---- server side ----------------------------------------------------------
|
|
3520
|
-
/** Resolve the caller's role on the server. Wire this to YOUR auth: by default it
|
|
3521
|
-
* reads \`event.locals.role\` - set it in \`hooks.server.ts\` from the session. */
|
|
3522
|
-
export function getServerRole(event: { locals?: { role?: unknown } }): AppRole {
|
|
3523
|
-
const r = event?.locals?.role
|
|
3524
|
-
return (typeof r === 'string' && (ROLES as string[]).includes(r) ? r : ${JSON.stringify(defaultRole)}) as AppRole
|
|
3525
|
-
}
|
|
3526
|
-
/** Authorize a CRUD action for a role - used by the API routes' \`authorize\` hook.
|
|
3527
|
-
* \`screenIds\` are the screen(s) bound to this route's entity: a read is allowed
|
|
3528
|
-
* only if the role can open at least one of them (an entity with no screen of its
|
|
3529
|
-
* own - e.g. a relation lookup target - has nothing to gate reads by, so it stays
|
|
3530
|
-
* open). Writes are still governed purely by \`can()\`. */
|
|
3531
|
-
export function authorizeAction(role: AppRole, action: 'read' | WriteAction, screenIds: string[] = []): boolean {
|
|
3532
|
-
if (action !== 'read') return can(role, action)
|
|
3533
|
-
if (screenIds.length === 0) return true
|
|
3534
|
-
return screenIds.some((id) => canScreen(role, id))
|
|
3535
|
-
}
|
|
3491
|
+
contents: `import { writable } from 'svelte/store'
|
|
3492
|
+
|
|
3493
|
+
export type AppRole = ${roleUnion}
|
|
3494
|
+
export type WriteAction = 'create' | 'update' | 'delete'
|
|
3495
|
+
export const ROLES: AppRole[] = ${JSON.stringify(roleNames)} as AppRole[]
|
|
3496
|
+
|
|
3497
|
+
const SCREENS: Record<AppRole, '*' | string[]> = {
|
|
3498
|
+
${screensEntries}
|
|
3499
|
+
}
|
|
3500
|
+
const ACTIONS: Record<AppRole, '*' | WriteAction[]> = {
|
|
3501
|
+
${actionsEntries}
|
|
3502
|
+
}
|
|
3503
|
+
|
|
3504
|
+
/** The signed-in user's role. Set it after login (e.g. from the session);
|
|
3505
|
+
* defaults to the project's default role. Read it in components as \`$currentRole\`. */
|
|
3506
|
+
export const currentRole = writable<AppRole>(${JSON.stringify(defaultRole)})
|
|
3507
|
+
|
|
3508
|
+
/** May this role open the given screen id? */
|
|
3509
|
+
export function canScreen(role: AppRole, screenId: string): boolean {
|
|
3510
|
+
const s = SCREENS[role]
|
|
3511
|
+
return s === '*' || (Array.isArray(s) && s.includes(screenId))
|
|
3512
|
+
}
|
|
3513
|
+
/** May this role perform a write action? (Reads are implied by screen access.) */
|
|
3514
|
+
export function can(role: AppRole, action: WriteAction): boolean {
|
|
3515
|
+
const a = ACTIONS[role]
|
|
3516
|
+
return a === '*' || (Array.isArray(a) && a.includes(action))
|
|
3517
|
+
}
|
|
3518
|
+
|
|
3519
|
+
// ---- server side ----------------------------------------------------------
|
|
3520
|
+
/** Resolve the caller's role on the server. Wire this to YOUR auth: by default it
|
|
3521
|
+
* reads \`event.locals.role\` - set it in \`hooks.server.ts\` from the session. */
|
|
3522
|
+
export function getServerRole(event: { locals?: { role?: unknown } }): AppRole {
|
|
3523
|
+
const r = event?.locals?.role
|
|
3524
|
+
return (typeof r === 'string' && (ROLES as string[]).includes(r) ? r : ${JSON.stringify(defaultRole)}) as AppRole
|
|
3525
|
+
}
|
|
3526
|
+
/** Authorize a CRUD action for a role - used by the API routes' \`authorize\` hook.
|
|
3527
|
+
* \`screenIds\` are the screen(s) bound to this route's entity: a read is allowed
|
|
3528
|
+
* only if the role can open at least one of them (an entity with no screen of its
|
|
3529
|
+
* own - e.g. a relation lookup target - has nothing to gate reads by, so it stays
|
|
3530
|
+
* open). Writes are still governed purely by \`can()\`. */
|
|
3531
|
+
export function authorizeAction(role: AppRole, action: 'read' | WriteAction, screenIds: string[] = []): boolean {
|
|
3532
|
+
if (action !== 'read') return can(role, action)
|
|
3533
|
+
if (screenIds.length === 0) return true
|
|
3534
|
+
return screenIds.some((id) => canScreen(role, id))
|
|
3535
|
+
}
|
|
3536
3536
|
`,
|
|
3537
3537
|
};
|
|
3538
3538
|
}
|
|
@@ -3563,862 +3563,862 @@ function authFiles(project, dbBacked = false, accessEnabled = false, tenantColum
|
|
|
3563
3563
|
const seedLiteral = users
|
|
3564
3564
|
.map((u) => ` { email: ${JSON.stringify(u.email)}, name: ${JSON.stringify(u.name)}, role: ${JSON.stringify(u.role)}, password: ${JSON.stringify(u.password)} }`)
|
|
3565
3565
|
.join(',\n');
|
|
3566
|
-
const authTs = `// Regenerated by SvGrid Studio. Dependency-free auth: PBKDF2 password hashing +
|
|
3567
|
-
// stateless HMAC-signed session cookies (Web Crypto - runs on Node and edge runtimes).
|
|
3568
|
-
import { env } from '$env/dynamic/private'
|
|
3569
|
-
|
|
3570
|
-
export const SESSION_COOKIE = 'sv_session'
|
|
3571
|
-
export const SESSION_MAX_AGE = 60 * 60 * 24 * 7 // 7 days (seconds)
|
|
3572
|
-
|
|
3573
|
-
export type SessionUser = { email: string; name: string; role: string }
|
|
3574
|
-
|
|
3575
|
-
const enc = new TextEncoder()
|
|
3576
|
-
// Signing secret. Set SESSION_SECRET in the environment for production (see .env.example).
|
|
3577
|
-
const secret = () => env.SESSION_SECRET || 'dev-insecure-secret-change-me'
|
|
3578
|
-
|
|
3579
|
-
function b64url(bytes: ArrayBuffer | Uint8Array): string {
|
|
3580
|
-
const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
|
|
3581
|
-
let s = ''
|
|
3582
|
-
for (const byte of b) s += String.fromCharCode(byte)
|
|
3583
|
-
return btoa(s).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')
|
|
3584
|
-
}
|
|
3585
|
-
function fromB64url(s: string): Uint8Array {
|
|
3586
|
-
const p = s.replace(/-/g, '+').replace(/_/g, '/')
|
|
3587
|
-
const bin = atob(p + '==='.slice((p.length + 3) % 4))
|
|
3588
|
-
const out = new Uint8Array(bin.length)
|
|
3589
|
-
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
|
3590
|
-
return out
|
|
3591
|
-
}
|
|
3592
|
-
function timingSafeEqual(a: string, b: string): boolean {
|
|
3593
|
-
if (a.length !== b.length) return false
|
|
3594
|
-
let out = 0
|
|
3595
|
-
for (let i = 0; i < a.length; i++) out |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
|
3596
|
-
return out === 0
|
|
3597
|
-
}
|
|
3598
|
-
async function hmac(data: string): Promise<string> {
|
|
3599
|
-
const key = await crypto.subtle.importKey('raw', enc.encode(secret()), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
|
|
3600
|
-
return b64url(await crypto.subtle.sign('HMAC', key, enc.encode(data)))
|
|
3601
|
-
}
|
|
3602
|
-
|
|
3603
|
-
/** Sign a stateless session token: base64url(payload).hmac(payload). */
|
|
3604
|
-
export async function signSession(user: SessionUser): Promise<string> {
|
|
3605
|
-
const payload = { ...user, exp: Math.floor(Date.now() / 1000) + SESSION_MAX_AGE }
|
|
3606
|
-
const body = b64url(enc.encode(JSON.stringify(payload)))
|
|
3607
|
-
return body + '.' + (await hmac(body))
|
|
3608
|
-
}
|
|
3609
|
-
/** Verify a token; returns the user, or null if missing / tampered / expired. */
|
|
3610
|
-
export async function readSession(token: string | undefined): Promise<SessionUser | null> {
|
|
3611
|
-
if (!token) return null
|
|
3612
|
-
const dot = token.lastIndexOf('.')
|
|
3613
|
-
if (dot < 0) return null
|
|
3614
|
-
const body = token.slice(0, dot)
|
|
3615
|
-
const sig = token.slice(dot + 1)
|
|
3616
|
-
if (!timingSafeEqual(sig, await hmac(body))) return null
|
|
3617
|
-
try {
|
|
3618
|
-
const p = JSON.parse(new TextDecoder().decode(fromB64url(body))) as SessionUser & { exp: number }
|
|
3619
|
-
if (typeof p.exp !== 'number' || p.exp * 1000 < Date.now()) return null
|
|
3620
|
-
return { email: p.email, name: p.name, role: p.role }
|
|
3621
|
-
} catch {
|
|
3622
|
-
return null
|
|
3623
|
-
}
|
|
3624
|
-
}
|
|
3625
|
-
|
|
3626
|
-
// ---- password hashing (PBKDF2) --------------------------------------------
|
|
3627
|
-
// Use these to store hashed passwords in a real user store: keep hashPassword()'s
|
|
3628
|
-
// output as \`passwordHash\`, then check with verifyPassword(input, passwordHash).
|
|
3629
|
-
export async function hashPassword(password: string, salt?: string): Promise<string> {
|
|
3630
|
-
const s = salt ?? b64url(crypto.getRandomValues(new Uint8Array(16)))
|
|
3631
|
-
const key = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveBits'])
|
|
3632
|
-
const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt: enc.encode(s), iterations: 100000, hash: 'SHA-256' }, key, 256)
|
|
3633
|
-
return s + ':' + b64url(bits)
|
|
3634
|
-
}
|
|
3635
|
-
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
|
3636
|
-
const salt = stored.split(':')[0]
|
|
3637
|
-
if (!salt) return false
|
|
3638
|
-
return timingSafeEqual(await hashPassword(password, salt), stored)
|
|
3639
|
-
}
|
|
3640
|
-
|
|
3641
|
-
// ---- password reset (stateless, signed link) ------------------------------
|
|
3642
|
-
const RESET_MAX_AGE = 60 * 30 // 30 minutes
|
|
3643
|
-
/** A short-lived, signed password-reset token for an email (no DB row needed). */
|
|
3644
|
-
export async function signReset(email: string): Promise<string> {
|
|
3645
|
-
const body = b64url(enc.encode(JSON.stringify({ email, exp: Math.floor(Date.now() / 1000) + RESET_MAX_AGE, k: 'reset' })))
|
|
3646
|
-
return body + '.' + (await hmac(body))
|
|
3647
|
-
}
|
|
3648
|
-
/** Verify a reset token; returns the email, or null if invalid / expired. */
|
|
3649
|
-
export async function readReset(token: string | undefined): Promise<string | null> {
|
|
3650
|
-
if (!token) return null
|
|
3651
|
-
const dot = token.lastIndexOf('.')
|
|
3652
|
-
if (dot < 0) return null
|
|
3653
|
-
const body = token.slice(0, dot)
|
|
3654
|
-
if (!timingSafeEqual(token.slice(dot + 1), await hmac(body))) return null
|
|
3655
|
-
try {
|
|
3656
|
-
const p = JSON.parse(new TextDecoder().decode(fromB64url(body))) as { email: string; exp: number; k: string }
|
|
3657
|
-
if (p.k !== 'reset' || typeof p.exp !== 'number' || p.exp * 1000 < Date.now()) return null
|
|
3658
|
-
return p.email
|
|
3659
|
-
} catch {
|
|
3660
|
-
return null
|
|
3661
|
-
}
|
|
3662
|
-
}
|
|
3663
|
-
|
|
3664
|
-
/** Deliver a password-reset link. STUB: logs it in dev - wire your email provider
|
|
3665
|
-
* (Resend / SendGrid / Postmark / SMTP) here for production. */
|
|
3666
|
-
export async function sendResetEmail(email: string, link: string): Promise<void> {
|
|
3667
|
-
console.log('[auth] password reset for ' + email + ' -> ' + link)
|
|
3668
|
-
}
|
|
3669
|
-
${twoFactor ? `
|
|
3670
|
-
// ---- email two-factor challenge (stateless, signed) -----------------------
|
|
3671
|
-
export const TFA_COOKIE = 'sv_2fa'
|
|
3672
|
-
const TFA_MAX_AGE = 60 * 10 // 10 minutes
|
|
3673
|
-
/** A random 6-digit one-time code. */
|
|
3674
|
-
export function otpCode(): string {
|
|
3675
|
-
return String(crypto.getRandomValues(new Uint32Array(1))[0]! % 1000000).padStart(6, '0')
|
|
3676
|
-
}
|
|
3677
|
-
async function codeHash(email: string, code: string): Promise<string> {
|
|
3678
|
-
const d = await crypto.subtle.digest('SHA-256', enc.encode(email + ':' + code))
|
|
3679
|
-
return b64url(d)
|
|
3680
|
-
}
|
|
3681
|
-
/** A signed pending-2FA token binding an email to a hashed code (the code itself is emailed). */
|
|
3682
|
-
export async function signChallenge(email: string, code: string): Promise<string> {
|
|
3683
|
-
const body = b64url(enc.encode(JSON.stringify({ email, ch: await codeHash(email, code), exp: Math.floor(Date.now() / 1000) + TFA_MAX_AGE, k: '2fa' })))
|
|
3684
|
-
return body + '.' + (await hmac(body))
|
|
3685
|
-
}
|
|
3686
|
-
/** Verify a submitted code against the pending token; returns the email or null. */
|
|
3687
|
-
export async function readChallenge(token: string | undefined, code: string): Promise<string | null> {
|
|
3688
|
-
if (!token) return null
|
|
3689
|
-
const dot = token.lastIndexOf('.')
|
|
3690
|
-
if (dot < 0) return null
|
|
3691
|
-
const body = token.slice(0, dot)
|
|
3692
|
-
if (!timingSafeEqual(token.slice(dot + 1), await hmac(body))) return null
|
|
3693
|
-
try {
|
|
3694
|
-
const p = JSON.parse(new TextDecoder().decode(fromB64url(body))) as { email: string; ch: string; exp: number; k: string }
|
|
3695
|
-
if (p.k !== '2fa' || typeof p.exp !== 'number' || p.exp * 1000 < Date.now()) return null
|
|
3696
|
-
if (!timingSafeEqual(p.ch, await codeHash(p.email, code))) return null
|
|
3697
|
-
return p.email
|
|
3698
|
-
} catch {
|
|
3699
|
-
return null
|
|
3700
|
-
}
|
|
3701
|
-
}
|
|
3566
|
+
const authTs = `// Regenerated by SvGrid Studio. Dependency-free auth: PBKDF2 password hashing +
|
|
3567
|
+
// stateless HMAC-signed session cookies (Web Crypto - runs on Node and edge runtimes).
|
|
3568
|
+
import { env } from '$env/dynamic/private'
|
|
3569
|
+
|
|
3570
|
+
export const SESSION_COOKIE = 'sv_session'
|
|
3571
|
+
export const SESSION_MAX_AGE = 60 * 60 * 24 * 7 // 7 days (seconds)
|
|
3572
|
+
|
|
3573
|
+
export type SessionUser = { email: string; name: string; role: string }
|
|
3574
|
+
|
|
3575
|
+
const enc = new TextEncoder()
|
|
3576
|
+
// Signing secret. Set SESSION_SECRET in the environment for production (see .env.example).
|
|
3577
|
+
const secret = () => env.SESSION_SECRET || 'dev-insecure-secret-change-me'
|
|
3578
|
+
|
|
3579
|
+
function b64url(bytes: ArrayBuffer | Uint8Array): string {
|
|
3580
|
+
const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
|
|
3581
|
+
let s = ''
|
|
3582
|
+
for (const byte of b) s += String.fromCharCode(byte)
|
|
3583
|
+
return btoa(s).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')
|
|
3584
|
+
}
|
|
3585
|
+
function fromB64url(s: string): Uint8Array {
|
|
3586
|
+
const p = s.replace(/-/g, '+').replace(/_/g, '/')
|
|
3587
|
+
const bin = atob(p + '==='.slice((p.length + 3) % 4))
|
|
3588
|
+
const out = new Uint8Array(bin.length)
|
|
3589
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
|
3590
|
+
return out
|
|
3591
|
+
}
|
|
3592
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
3593
|
+
if (a.length !== b.length) return false
|
|
3594
|
+
let out = 0
|
|
3595
|
+
for (let i = 0; i < a.length; i++) out |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
|
3596
|
+
return out === 0
|
|
3597
|
+
}
|
|
3598
|
+
async function hmac(data: string): Promise<string> {
|
|
3599
|
+
const key = await crypto.subtle.importKey('raw', enc.encode(secret()), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
|
|
3600
|
+
return b64url(await crypto.subtle.sign('HMAC', key, enc.encode(data)))
|
|
3601
|
+
}
|
|
3602
|
+
|
|
3603
|
+
/** Sign a stateless session token: base64url(payload).hmac(payload). */
|
|
3604
|
+
export async function signSession(user: SessionUser): Promise<string> {
|
|
3605
|
+
const payload = { ...user, exp: Math.floor(Date.now() / 1000) + SESSION_MAX_AGE }
|
|
3606
|
+
const body = b64url(enc.encode(JSON.stringify(payload)))
|
|
3607
|
+
return body + '.' + (await hmac(body))
|
|
3608
|
+
}
|
|
3609
|
+
/** Verify a token; returns the user, or null if missing / tampered / expired. */
|
|
3610
|
+
export async function readSession(token: string | undefined): Promise<SessionUser | null> {
|
|
3611
|
+
if (!token) return null
|
|
3612
|
+
const dot = token.lastIndexOf('.')
|
|
3613
|
+
if (dot < 0) return null
|
|
3614
|
+
const body = token.slice(0, dot)
|
|
3615
|
+
const sig = token.slice(dot + 1)
|
|
3616
|
+
if (!timingSafeEqual(sig, await hmac(body))) return null
|
|
3617
|
+
try {
|
|
3618
|
+
const p = JSON.parse(new TextDecoder().decode(fromB64url(body))) as SessionUser & { exp: number }
|
|
3619
|
+
if (typeof p.exp !== 'number' || p.exp * 1000 < Date.now()) return null
|
|
3620
|
+
return { email: p.email, name: p.name, role: p.role }
|
|
3621
|
+
} catch {
|
|
3622
|
+
return null
|
|
3623
|
+
}
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
// ---- password hashing (PBKDF2) --------------------------------------------
|
|
3627
|
+
// Use these to store hashed passwords in a real user store: keep hashPassword()'s
|
|
3628
|
+
// output as \`passwordHash\`, then check with verifyPassword(input, passwordHash).
|
|
3629
|
+
export async function hashPassword(password: string, salt?: string): Promise<string> {
|
|
3630
|
+
const s = salt ?? b64url(crypto.getRandomValues(new Uint8Array(16)))
|
|
3631
|
+
const key = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveBits'])
|
|
3632
|
+
const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt: enc.encode(s), iterations: 100000, hash: 'SHA-256' }, key, 256)
|
|
3633
|
+
return s + ':' + b64url(bits)
|
|
3634
|
+
}
|
|
3635
|
+
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
|
3636
|
+
const salt = stored.split(':')[0]
|
|
3637
|
+
if (!salt) return false
|
|
3638
|
+
return timingSafeEqual(await hashPassword(password, salt), stored)
|
|
3639
|
+
}
|
|
3640
|
+
|
|
3641
|
+
// ---- password reset (stateless, signed link) ------------------------------
|
|
3642
|
+
const RESET_MAX_AGE = 60 * 30 // 30 minutes
|
|
3643
|
+
/** A short-lived, signed password-reset token for an email (no DB row needed). */
|
|
3644
|
+
export async function signReset(email: string): Promise<string> {
|
|
3645
|
+
const body = b64url(enc.encode(JSON.stringify({ email, exp: Math.floor(Date.now() / 1000) + RESET_MAX_AGE, k: 'reset' })))
|
|
3646
|
+
return body + '.' + (await hmac(body))
|
|
3647
|
+
}
|
|
3648
|
+
/** Verify a reset token; returns the email, or null if invalid / expired. */
|
|
3649
|
+
export async function readReset(token: string | undefined): Promise<string | null> {
|
|
3650
|
+
if (!token) return null
|
|
3651
|
+
const dot = token.lastIndexOf('.')
|
|
3652
|
+
if (dot < 0) return null
|
|
3653
|
+
const body = token.slice(0, dot)
|
|
3654
|
+
if (!timingSafeEqual(token.slice(dot + 1), await hmac(body))) return null
|
|
3655
|
+
try {
|
|
3656
|
+
const p = JSON.parse(new TextDecoder().decode(fromB64url(body))) as { email: string; exp: number; k: string }
|
|
3657
|
+
if (p.k !== 'reset' || typeof p.exp !== 'number' || p.exp * 1000 < Date.now()) return null
|
|
3658
|
+
return p.email
|
|
3659
|
+
} catch {
|
|
3660
|
+
return null
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3663
|
+
|
|
3664
|
+
/** Deliver a password-reset link. STUB: logs it in dev - wire your email provider
|
|
3665
|
+
* (Resend / SendGrid / Postmark / SMTP) here for production. */
|
|
3666
|
+
export async function sendResetEmail(email: string, link: string): Promise<void> {
|
|
3667
|
+
console.log('[auth] password reset for ' + email + ' -> ' + link)
|
|
3668
|
+
}
|
|
3669
|
+
${twoFactor ? `
|
|
3670
|
+
// ---- email two-factor challenge (stateless, signed) -----------------------
|
|
3671
|
+
export const TFA_COOKIE = 'sv_2fa'
|
|
3672
|
+
const TFA_MAX_AGE = 60 * 10 // 10 minutes
|
|
3673
|
+
/** A random 6-digit one-time code. */
|
|
3674
|
+
export function otpCode(): string {
|
|
3675
|
+
return String(crypto.getRandomValues(new Uint32Array(1))[0]! % 1000000).padStart(6, '0')
|
|
3676
|
+
}
|
|
3677
|
+
async function codeHash(email: string, code: string): Promise<string> {
|
|
3678
|
+
const d = await crypto.subtle.digest('SHA-256', enc.encode(email + ':' + code))
|
|
3679
|
+
return b64url(d)
|
|
3680
|
+
}
|
|
3681
|
+
/** A signed pending-2FA token binding an email to a hashed code (the code itself is emailed). */
|
|
3682
|
+
export async function signChallenge(email: string, code: string): Promise<string> {
|
|
3683
|
+
const body = b64url(enc.encode(JSON.stringify({ email, ch: await codeHash(email, code), exp: Math.floor(Date.now() / 1000) + TFA_MAX_AGE, k: '2fa' })))
|
|
3684
|
+
return body + '.' + (await hmac(body))
|
|
3685
|
+
}
|
|
3686
|
+
/** Verify a submitted code against the pending token; returns the email or null. */
|
|
3687
|
+
export async function readChallenge(token: string | undefined, code: string): Promise<string | null> {
|
|
3688
|
+
if (!token) return null
|
|
3689
|
+
const dot = token.lastIndexOf('.')
|
|
3690
|
+
if (dot < 0) return null
|
|
3691
|
+
const body = token.slice(0, dot)
|
|
3692
|
+
if (!timingSafeEqual(token.slice(dot + 1), await hmac(body))) return null
|
|
3693
|
+
try {
|
|
3694
|
+
const p = JSON.parse(new TextDecoder().decode(fromB64url(body))) as { email: string; ch: string; exp: number; k: string }
|
|
3695
|
+
if (p.k !== '2fa' || typeof p.exp !== 'number' || p.exp * 1000 < Date.now()) return null
|
|
3696
|
+
if (!timingSafeEqual(p.ch, await codeHash(p.email, code))) return null
|
|
3697
|
+
return p.email
|
|
3698
|
+
} catch {
|
|
3699
|
+
return null
|
|
3700
|
+
}
|
|
3701
|
+
}
|
|
3702
3702
|
` : ''}`;
|
|
3703
3703
|
const usersTs = dbBacked
|
|
3704
|
-
? `// Regenerated by SvGrid Studio. DB-backed user store: reads the \`auth_users\` table
|
|
3705
|
-
// (Drizzle) with hashed passwords. The demo users below are seeded ONCE, on the first
|
|
3706
|
-
// login, if the table is empty - delete the seed for production, or manage users in the DB.
|
|
3707
|
-
import { eq } from 'drizzle-orm'
|
|
3708
|
-
import { db } from './db/index'
|
|
3709
|
-
import { authUsers, type AuthUserRow } from './db/schema'
|
|
3710
|
-
import { hashPassword } from './auth'
|
|
3711
|
-
|
|
3712
|
-
export type AppUser = AuthUserRow
|
|
3713
|
-
|
|
3714
|
-
const SEED: Array<{ email: string; name: string; role: string; password: string }> = [
|
|
3715
|
-
${seedLiteral},
|
|
3716
|
-
]
|
|
3717
|
-
|
|
3718
|
-
// Seed the demo users once (idempotent + concurrency-safe: the unique email index
|
|
3719
|
-
// rejects a racing duplicate, which we swallow). Cached per process after the first run.
|
|
3720
|
-
let seeding: Promise<void> | null = null
|
|
3721
|
-
function ensureSeeded(): Promise<void> {
|
|
3722
|
-
if (!seeding) seeding = (async () => {
|
|
3723
|
-
const existing = await db.select({ email: authUsers.email }).from(authUsers).limit(1)
|
|
3724
|
-
if (existing.length) return
|
|
3725
|
-
for (const u of SEED) {
|
|
3726
|
-
try {
|
|
3727
|
-
await db.insert(authUsers).values({ email: u.email, name: u.name, role: u.role, passwordHash: await hashPassword(u.password) })
|
|
3728
|
-
} catch { /* unique-email race: another request seeded it first */ }
|
|
3729
|
-
}
|
|
3730
|
-
})()
|
|
3731
|
-
return seeding
|
|
3732
|
-
}
|
|
3733
|
-
|
|
3734
|
-
export async function findUser(email: string): Promise<AppUser | undefined> {
|
|
3735
|
-
await ensureSeeded()
|
|
3736
|
-
const e = email.trim().toLowerCase()
|
|
3737
|
-
return (await db.select().from(authUsers).where(eq(authUsers.email, e))).at(0)
|
|
3738
|
-
}
|
|
3739
|
-
|
|
3740
|
-
/** Public view of a user (no password hash) - for the admin list. */
|
|
3741
|
-
export type PublicUser = { email: string; name: string; role: string }
|
|
3742
|
-
export async function listUsers(): Promise<PublicUser[]> {
|
|
3743
|
-
await ensureSeeded()
|
|
3744
|
-
return db.select({ email: authUsers.email, name: authUsers.name, role: authUsers.role }).from(authUsers)
|
|
3745
|
-
}
|
|
3746
|
-
|
|
3747
|
-
/** Create a user (hashed password). Returns undefined if the email already exists. */
|
|
3748
|
-
export async function createUser(input: { email: string; name: string; password: string; role: string }): Promise<AppUser | undefined> {
|
|
3749
|
-
const email = input.email.trim().toLowerCase()
|
|
3750
|
-
if (await findUser(email)) return undefined
|
|
3751
|
-
const passwordHash = await hashPassword(input.password)
|
|
3752
|
-
try {
|
|
3753
|
-
await db.insert(authUsers).values({ email, name: input.name, role: input.role, passwordHash })
|
|
3754
|
-
} catch {
|
|
3755
|
-
return undefined // unique-email race
|
|
3756
|
-
}
|
|
3757
|
-
return findUser(email)
|
|
3758
|
-
}
|
|
3759
|
-
|
|
3760
|
-
export async function updatePassword(email: string, newPassword: string): Promise<void> {
|
|
3761
|
-
await db.update(authUsers).set({ passwordHash: await hashPassword(newPassword) }).where(eq(authUsers.email, email.trim().toLowerCase()))
|
|
3762
|
-
}
|
|
3763
|
-
export async function setUserRole(email: string, role: string): Promise<void> {
|
|
3764
|
-
await db.update(authUsers).set({ role }).where(eq(authUsers.email, email.trim().toLowerCase()))
|
|
3765
|
-
}
|
|
3766
|
-
export async function deleteUser(email: string): Promise<void> {
|
|
3767
|
-
await db.delete(authUsers).where(eq(authUsers.email, email.trim().toLowerCase()))
|
|
3768
|
-
}${twoFactor ? `
|
|
3769
|
-
export async function setTwoFactor(email: string, on: boolean): Promise<void> {
|
|
3770
|
-
await db.update(authUsers).set({ twoFactor: on }).where(eq(authUsers.email, email.trim().toLowerCase()))
|
|
3771
|
-
}` : ''}
|
|
3704
|
+
? `// Regenerated by SvGrid Studio. DB-backed user store: reads the \`auth_users\` table
|
|
3705
|
+
// (Drizzle) with hashed passwords. The demo users below are seeded ONCE, on the first
|
|
3706
|
+
// login, if the table is empty - delete the seed for production, or manage users in the DB.
|
|
3707
|
+
import { eq } from 'drizzle-orm'
|
|
3708
|
+
import { db } from './db/index'
|
|
3709
|
+
import { authUsers, type AuthUserRow } from './db/schema'
|
|
3710
|
+
import { hashPassword } from './auth'
|
|
3711
|
+
|
|
3712
|
+
export type AppUser = AuthUserRow
|
|
3713
|
+
|
|
3714
|
+
const SEED: Array<{ email: string; name: string; role: string; password: string }> = [
|
|
3715
|
+
${seedLiteral},
|
|
3716
|
+
]
|
|
3717
|
+
|
|
3718
|
+
// Seed the demo users once (idempotent + concurrency-safe: the unique email index
|
|
3719
|
+
// rejects a racing duplicate, which we swallow). Cached per process after the first run.
|
|
3720
|
+
let seeding: Promise<void> | null = null
|
|
3721
|
+
function ensureSeeded(): Promise<void> {
|
|
3722
|
+
if (!seeding) seeding = (async () => {
|
|
3723
|
+
const existing = await db.select({ email: authUsers.email }).from(authUsers).limit(1)
|
|
3724
|
+
if (existing.length) return
|
|
3725
|
+
for (const u of SEED) {
|
|
3726
|
+
try {
|
|
3727
|
+
await db.insert(authUsers).values({ email: u.email, name: u.name, role: u.role, passwordHash: await hashPassword(u.password) })
|
|
3728
|
+
} catch { /* unique-email race: another request seeded it first */ }
|
|
3729
|
+
}
|
|
3730
|
+
})()
|
|
3731
|
+
return seeding
|
|
3732
|
+
}
|
|
3733
|
+
|
|
3734
|
+
export async function findUser(email: string): Promise<AppUser | undefined> {
|
|
3735
|
+
await ensureSeeded()
|
|
3736
|
+
const e = email.trim().toLowerCase()
|
|
3737
|
+
return (await db.select().from(authUsers).where(eq(authUsers.email, e))).at(0)
|
|
3738
|
+
}
|
|
3739
|
+
|
|
3740
|
+
/** Public view of a user (no password hash) - for the admin list. */
|
|
3741
|
+
export type PublicUser = { email: string; name: string; role: string }
|
|
3742
|
+
export async function listUsers(): Promise<PublicUser[]> {
|
|
3743
|
+
await ensureSeeded()
|
|
3744
|
+
return db.select({ email: authUsers.email, name: authUsers.name, role: authUsers.role }).from(authUsers)
|
|
3745
|
+
}
|
|
3746
|
+
|
|
3747
|
+
/** Create a user (hashed password). Returns undefined if the email already exists. */
|
|
3748
|
+
export async function createUser(input: { email: string; name: string; password: string; role: string }): Promise<AppUser | undefined> {
|
|
3749
|
+
const email = input.email.trim().toLowerCase()
|
|
3750
|
+
if (await findUser(email)) return undefined
|
|
3751
|
+
const passwordHash = await hashPassword(input.password)
|
|
3752
|
+
try {
|
|
3753
|
+
await db.insert(authUsers).values({ email, name: input.name, role: input.role, passwordHash })
|
|
3754
|
+
} catch {
|
|
3755
|
+
return undefined // unique-email race
|
|
3756
|
+
}
|
|
3757
|
+
return findUser(email)
|
|
3758
|
+
}
|
|
3759
|
+
|
|
3760
|
+
export async function updatePassword(email: string, newPassword: string): Promise<void> {
|
|
3761
|
+
await db.update(authUsers).set({ passwordHash: await hashPassword(newPassword) }).where(eq(authUsers.email, email.trim().toLowerCase()))
|
|
3762
|
+
}
|
|
3763
|
+
export async function setUserRole(email: string, role: string): Promise<void> {
|
|
3764
|
+
await db.update(authUsers).set({ role }).where(eq(authUsers.email, email.trim().toLowerCase()))
|
|
3765
|
+
}
|
|
3766
|
+
export async function deleteUser(email: string): Promise<void> {
|
|
3767
|
+
await db.delete(authUsers).where(eq(authUsers.email, email.trim().toLowerCase()))
|
|
3768
|
+
}${twoFactor ? `
|
|
3769
|
+
export async function setTwoFactor(email: string, on: boolean): Promise<void> {
|
|
3770
|
+
await db.update(authUsers).set({ twoFactor: on }).where(eq(authUsers.email, email.trim().toLowerCase()))
|
|
3771
|
+
}` : ''}
|
|
3772
3772
|
`
|
|
3773
|
-
: `// Regenerated by SvGrid Studio. DEMO user store - replace with your own (a DB table,
|
|
3774
|
-
// an external identity provider, ...). The seed passwords below are demo defaults
|
|
3775
|
-
// (printed on the login page); they are HASHED (PBKDF2) at startup and never compared
|
|
3776
|
-
// in plaintext. Change them for anything real. Tip: turn on the Drizzle data layer and
|
|
3777
|
-
// this becomes a real \`auth_users\` DB table.
|
|
3778
|
-
import { hashPassword } from './auth'
|
|
3779
|
-
|
|
3780
|
-
export type AppUser = { email: string; name: string; role: string; passwordHash: string }
|
|
3781
|
-
|
|
3782
|
-
const SEED: Array<{ email: string; name: string; role: string; password: string }> = [
|
|
3783
|
-
${usersLiteral},
|
|
3784
|
-
]
|
|
3785
|
-
|
|
3786
|
-
// Hash the seed passwords once at module load (top-level await) so the store holds
|
|
3787
|
-
// only PBKDF2 hashes - login verifies with verifyPassword(), never a plaintext compare.
|
|
3788
|
-
export const USERS: AppUser[] = await Promise.all(
|
|
3789
|
-
SEED.map(async (u) => ({ email: u.email, name: u.name, role: u.role, passwordHash: await hashPassword(u.password) })),
|
|
3790
|
-
)
|
|
3791
|
-
|
|
3792
|
-
export function findUser(email: string): AppUser | undefined {
|
|
3793
|
-
const e = email.trim().toLowerCase()
|
|
3794
|
-
return USERS.find((u) => u.email.toLowerCase() === e)
|
|
3795
|
-
}
|
|
3773
|
+
: `// Regenerated by SvGrid Studio. DEMO user store - replace with your own (a DB table,
|
|
3774
|
+
// an external identity provider, ...). The seed passwords below are demo defaults
|
|
3775
|
+
// (printed on the login page); they are HASHED (PBKDF2) at startup and never compared
|
|
3776
|
+
// in plaintext. Change them for anything real. Tip: turn on the Drizzle data layer and
|
|
3777
|
+
// this becomes a real \`auth_users\` DB table.
|
|
3778
|
+
import { hashPassword } from './auth'
|
|
3779
|
+
|
|
3780
|
+
export type AppUser = { email: string; name: string; role: string; passwordHash: string }
|
|
3781
|
+
|
|
3782
|
+
const SEED: Array<{ email: string; name: string; role: string; password: string }> = [
|
|
3783
|
+
${usersLiteral},
|
|
3784
|
+
]
|
|
3785
|
+
|
|
3786
|
+
// Hash the seed passwords once at module load (top-level await) so the store holds
|
|
3787
|
+
// only PBKDF2 hashes - login verifies with verifyPassword(), never a plaintext compare.
|
|
3788
|
+
export const USERS: AppUser[] = await Promise.all(
|
|
3789
|
+
SEED.map(async (u) => ({ email: u.email, name: u.name, role: u.role, passwordHash: await hashPassword(u.password) })),
|
|
3790
|
+
)
|
|
3791
|
+
|
|
3792
|
+
export function findUser(email: string): AppUser | undefined {
|
|
3793
|
+
const e = email.trim().toLowerCase()
|
|
3794
|
+
return USERS.find((u) => u.email.toLowerCase() === e)
|
|
3795
|
+
}
|
|
3796
3796
|
`;
|
|
3797
|
-
const hooksTs = `import type { Handle } from '@sveltejs/kit'
|
|
3798
|
-
import { SESSION_COOKIE, readSession } from '../lib/server/auth'
|
|
3799
|
-
|
|
3800
|
-
// Resolve the signed session on every request into event.locals - the RBAC layer
|
|
3801
|
-
// (getServerRole / authorize) and the app shell read event.locals.role from here.
|
|
3802
|
-
export const handle: Handle = async ({ event, resolve }) => {
|
|
3803
|
-
const user = await readSession(event.cookies.get(SESSION_COOKIE))
|
|
3804
|
-
event.locals.user = user ?? undefined
|
|
3805
|
-
event.locals.role = user?.role${tenantColumn ? `
|
|
3806
|
-
// Multi-tenancy: the tenant travels on the session, so every scoped API route
|
|
3807
|
-
// reads it from here rather than trusting anything the client sends.
|
|
3808
|
-
event.locals.${tenantColumn} = (user as { ${tenantColumn}?: string } | null | undefined)?.${tenantColumn}` : ''}
|
|
3809
|
-
return resolve(event)
|
|
3810
|
-
}
|
|
3797
|
+
const hooksTs = `import type { Handle } from '@sveltejs/kit'
|
|
3798
|
+
import { SESSION_COOKIE, readSession } from '../lib/server/auth'
|
|
3799
|
+
|
|
3800
|
+
// Resolve the signed session on every request into event.locals - the RBAC layer
|
|
3801
|
+
// (getServerRole / authorize) and the app shell read event.locals.role from here.
|
|
3802
|
+
export const handle: Handle = async ({ event, resolve }) => {
|
|
3803
|
+
const user = await readSession(event.cookies.get(SESSION_COOKIE))
|
|
3804
|
+
event.locals.user = user ?? undefined
|
|
3805
|
+
event.locals.role = user?.role${tenantColumn ? `
|
|
3806
|
+
// Multi-tenancy: the tenant travels on the session, so every scoped API route
|
|
3807
|
+
// reads it from here rather than trusting anything the client sends.
|
|
3808
|
+
event.locals.${tenantColumn} = (user as { ${tenantColumn}?: string } | null | undefined)?.${tenantColumn}` : ''}
|
|
3809
|
+
return resolve(event)
|
|
3810
|
+
}
|
|
3811
3811
|
`;
|
|
3812
|
-
const localsDts = `import type { SessionUser } from '../lib/server/auth'
|
|
3813
|
-
|
|
3814
|
-
declare global {
|
|
3815
|
-
namespace App {
|
|
3816
|
-
interface Locals {
|
|
3817
|
-
user?: SessionUser
|
|
3818
|
-
role?: string
|
|
3819
|
-
}
|
|
3820
|
-
}
|
|
3821
|
-
}
|
|
3822
|
-
|
|
3823
|
-
export {}
|
|
3812
|
+
const localsDts = `import type { SessionUser } from '../lib/server/auth'
|
|
3813
|
+
|
|
3814
|
+
declare global {
|
|
3815
|
+
namespace App {
|
|
3816
|
+
interface Locals {
|
|
3817
|
+
user?: SessionUser
|
|
3818
|
+
role?: string
|
|
3819
|
+
}
|
|
3820
|
+
}
|
|
3821
|
+
}
|
|
3822
|
+
|
|
3823
|
+
export {}
|
|
3824
3824
|
`;
|
|
3825
3825
|
// Routes that render bare (no app shell) and don't require a session.
|
|
3826
3826
|
const publicRoutes = ['/login', ...(twoFactor ? ['/login/verify'] : []), ...(register ? ['/register', '/forgot-password', '/reset-password'] : [])];
|
|
3827
|
-
const layoutServerTs = `import type { LayoutServerLoad } from './$types'
|
|
3828
|
-
${protect ? `import { redirect } from '@sveltejs/kit'\n\nconst PUBLIC = new Set(${JSON.stringify(publicRoutes)})\n` : ''}
|
|
3829
|
-
// Expose the signed-in user + role to every page (read as \`data.user\` / \`data.role\`,
|
|
3830
|
-
// or \`page.data\`).${protect ? ' Unauthenticated visitors are sent to /login.' : ''}
|
|
3831
|
-
export const load: LayoutServerLoad = async ({ locals${protect ? ', url' : ''} }) => {
|
|
3832
|
-
${protect ? " if (!locals.user && !PUBLIC.has(url.pathname)) throw redirect(302, '/login?redirectTo=' + encodeURIComponent(url.pathname))\n" : ''} return { user: locals.user ?? null, role: locals.role ?? null }
|
|
3833
|
-
}
|
|
3827
|
+
const layoutServerTs = `import type { LayoutServerLoad } from './$types'
|
|
3828
|
+
${protect ? `import { redirect } from '@sveltejs/kit'\n\nconst PUBLIC = new Set(${JSON.stringify(publicRoutes)})\n` : ''}
|
|
3829
|
+
// Expose the signed-in user + role to every page (read as \`data.user\` / \`data.role\`,
|
|
3830
|
+
// or \`page.data\`).${protect ? ' Unauthenticated visitors are sent to /login.' : ''}
|
|
3831
|
+
export const load: LayoutServerLoad = async ({ locals${protect ? ', url' : ''} }) => {
|
|
3832
|
+
${protect ? " if (!locals.user && !PUBLIC.has(url.pathname)) throw redirect(302, '/login?redirectTo=' + encodeURIComponent(url.pathname))\n" : ''} return { user: locals.user ?? null, role: locals.role ?? null }
|
|
3833
|
+
}
|
|
3834
3834
|
`;
|
|
3835
3835
|
const loginCheck = dbBacked
|
|
3836
|
-
? `const user = await findUser(email)
|
|
3837
|
-
// DB-backed store: verify against the stored PBKDF2 hash.
|
|
3836
|
+
? `const user = await findUser(email)
|
|
3837
|
+
// DB-backed store: verify against the stored PBKDF2 hash.
|
|
3838
3838
|
if (!user || !(await verifyPassword(password, user.passwordHash))) return fail(401, { email, error: 'Invalid email or password.' })`
|
|
3839
|
-
: `const user = findUser(email)
|
|
3840
|
-
// Verify against the PBKDF2 hash of the seed (users store hashes it at startup).
|
|
3839
|
+
: `const user = findUser(email)
|
|
3840
|
+
// Verify against the PBKDF2 hash of the seed (users store hashes it at startup).
|
|
3841
3841
|
if (!user || !(await verifyPassword(password, user.passwordHash))) return fail(401, { email, error: 'Invalid email or password.' })`;
|
|
3842
3842
|
// 2FA (email code): after the password check, email a code + park a pending token,
|
|
3843
3843
|
// then send the user to /login/verify instead of issuing the session immediately.
|
|
3844
|
-
const twoFactorBranch = twoFactor ? `
|
|
3845
|
-
if (user.twoFactor) {
|
|
3846
|
-
const code = otpCode()
|
|
3847
|
-
await sendEmail(user.email, 'Your verification code', '<p>Your sign-in code is <strong>' + code + '</strong>. It expires in 10 minutes.</p>')
|
|
3848
|
-
cookies.set(TFA_COOKIE, await signChallenge(user.email, code), { path: '/', httpOnly: true, sameSite: 'lax', secure: url.hostname !== 'localhost' && url.hostname !== '127.0.0.1', maxAge: 600 })
|
|
3849
|
-
const rt = url.searchParams.get('redirectTo')
|
|
3850
|
-
throw redirect(302, '/login/verify' + (rt ? '?redirectTo=' + encodeURIComponent(rt) : ''))
|
|
3844
|
+
const twoFactorBranch = twoFactor ? `
|
|
3845
|
+
if (user.twoFactor) {
|
|
3846
|
+
const code = otpCode()
|
|
3847
|
+
await sendEmail(user.email, 'Your verification code', '<p>Your sign-in code is <strong>' + code + '</strong>. It expires in 10 minutes.</p>')
|
|
3848
|
+
cookies.set(TFA_COOKIE, await signChallenge(user.email, code), { path: '/', httpOnly: true, sameSite: 'lax', secure: url.hostname !== 'localhost' && url.hostname !== '127.0.0.1', maxAge: 600 })
|
|
3849
|
+
const rt = url.searchParams.get('redirectTo')
|
|
3850
|
+
throw redirect(302, '/login/verify' + (rt ? '?redirectTo=' + encodeURIComponent(rt) : ''))
|
|
3851
3851
|
}` : '';
|
|
3852
3852
|
const loginImports = twoFactor
|
|
3853
3853
|
? "import { SESSION_COOKIE, SESSION_MAX_AGE, TFA_COOKIE, signSession, verifyPassword, otpCode, signChallenge } from '../lib/server/auth'\nimport { findUser } from '$lib/server/users'\nimport { sendEmail } from '$lib/server/email'"
|
|
3854
3854
|
: `import { SESSION_COOKIE, SESSION_MAX_AGE, signSession, verifyPassword } from '../lib/server/auth'\nimport { findUser } from '$lib/server/users'`;
|
|
3855
|
-
const loginServerTs = `import { fail, redirect } from '@sveltejs/kit'
|
|
3856
|
-
import type { Actions, PageServerLoad } from './$types'
|
|
3857
|
-
${loginImports}
|
|
3858
|
-
|
|
3859
|
-
export const load: PageServerLoad = async ({ locals }) => {
|
|
3860
|
-
if (locals.user) throw redirect(302, '/')
|
|
3861
|
-
return {}
|
|
3862
|
-
}
|
|
3863
|
-
|
|
3864
|
-
export const actions: Actions = {
|
|
3865
|
-
default: async ({ request, cookies, url }) => {
|
|
3866
|
-
const form = await request.formData()
|
|
3867
|
-
const email = String(form.get('email') ?? '')
|
|
3868
|
-
const password = String(form.get('password') ?? '')
|
|
3869
|
-
${loginCheck}${twoFactorBranch}
|
|
3870
|
-
const token = await signSession({ email: user.email, name: user.name, role: user.role })
|
|
3871
|
-
cookies.set(SESSION_COOKIE, token, {
|
|
3872
|
-
path: '/', httpOnly: true, sameSite: 'lax',
|
|
3873
|
-
secure: url.hostname !== 'localhost' && url.hostname !== '127.0.0.1',
|
|
3874
|
-
maxAge: SESSION_MAX_AGE,
|
|
3875
|
-
})
|
|
3876
|
-
throw redirect(302, url.searchParams.get('redirectTo') || '/')
|
|
3877
|
-
},
|
|
3878
|
-
}
|
|
3855
|
+
const loginServerTs = `import { fail, redirect } from '@sveltejs/kit'
|
|
3856
|
+
import type { Actions, PageServerLoad } from './$types'
|
|
3857
|
+
${loginImports}
|
|
3858
|
+
|
|
3859
|
+
export const load: PageServerLoad = async ({ locals }) => {
|
|
3860
|
+
if (locals.user) throw redirect(302, '/')
|
|
3861
|
+
return {}
|
|
3862
|
+
}
|
|
3863
|
+
|
|
3864
|
+
export const actions: Actions = {
|
|
3865
|
+
default: async ({ request, cookies, url }) => {
|
|
3866
|
+
const form = await request.formData()
|
|
3867
|
+
const email = String(form.get('email') ?? '')
|
|
3868
|
+
const password = String(form.get('password') ?? '')
|
|
3869
|
+
${loginCheck}${twoFactorBranch}
|
|
3870
|
+
const token = await signSession({ email: user.email, name: user.name, role: user.role })
|
|
3871
|
+
cookies.set(SESSION_COOKIE, token, {
|
|
3872
|
+
path: '/', httpOnly: true, sameSite: 'lax',
|
|
3873
|
+
secure: url.hostname !== 'localhost' && url.hostname !== '127.0.0.1',
|
|
3874
|
+
maxAge: SESSION_MAX_AGE,
|
|
3875
|
+
})
|
|
3876
|
+
throw redirect(302, url.searchParams.get('redirectTo') || '/')
|
|
3877
|
+
},
|
|
3878
|
+
}
|
|
3879
3879
|
`;
|
|
3880
3880
|
// Shared full-screen auth-page styles (login / register / forgot / reset).
|
|
3881
|
-
const authStyle = `<style>
|
|
3882
|
-
.auth { display: grid; place-items: center; min-height: 100vh; padding: 24px; background: var(--sg-bg-subtle, #f8fafc); }
|
|
3883
|
-
.auth__card { width: 100%; max-width: 360px; display: flex; flex-direction: column; gap: 12px; padding: 28px; background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 16px; box-shadow: 0 12px 40px -12px rgba(15, 23, 42, 0.18); }
|
|
3884
|
-
.auth__title { margin: 0; font-size: 22px; font-weight: 720; letter-spacing: -0.02em; }
|
|
3885
|
-
.auth__sub { margin: -6px 0 6px; font-size: 13.5px; color: var(--sg-muted, #64748b); }
|
|
3886
|
-
.auth__field { display: flex; flex-direction: column; gap: 5px; font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); }
|
|
3887
|
-
.auth__field input { padding: 9px 11px; font: inherit; font-size: 14px; font-weight: 400; color: var(--sg-fg, #0f172a); background: var(--sg-input-bg, #fff); border: 1px solid var(--sg-input-border, #e6e8ec); border-radius: 9px; }
|
|
3888
|
-
.auth__field input:focus { outline: none; border-color: var(--sg-accent, #6366f1); box-shadow: 0 0 0 3px color-mix(in srgb, var(--sg-accent, #6366f1) 18%, transparent); }
|
|
3889
|
-
.auth__btn { margin-top: 4px; padding: 10px; font: inherit; font-size: 14px; font-weight: 640; color: var(--sg-on-accent, #fff); background: var(--sg-accent, #6366f1); border: none; border-radius: 9px; cursor: pointer; }
|
|
3890
|
-
.auth__btn:hover { filter: brightness(1.06); }
|
|
3891
|
-
.auth__err { margin: 0; padding: 8px 11px; font-size: 13px; color: var(--sg-danger, #b3261e); background: color-mix(in srgb, var(--sg-danger, #dc2626) 9%, var(--sg-bg, #fff)); border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 35%, var(--sg-border, #e6e8ec)); border-radius: 9px; }
|
|
3892
|
-
.auth__ok { margin: 0; padding: 8px 11px; font-size: 13px; color: #166534; background: color-mix(in srgb, #16a34a 9%, var(--sg-bg, #fff)); border: 1px solid color-mix(in srgb, #16a34a 35%, var(--sg-border, #e6e8ec)); border-radius: 9px; }
|
|
3893
|
-
.auth__hint { margin: 6px 0 0; font-size: 12px; color: var(--sg-muted, #94a3b8); text-align: center; }
|
|
3894
|
-
.auth__hint code { background: color-mix(in srgb, var(--sg-fg, #0f172a) 6%, transparent); padding: 1px 5px; border-radius: 5px; }
|
|
3895
|
-
.auth__links { margin: 2px 0 0; display: flex; justify-content: space-between; font-size: 12.5px; }
|
|
3896
|
-
.auth__links a { color: var(--sg-accent, #6366f1); text-decoration: none; font-weight: 600; }
|
|
3897
|
-
.auth__or { display: flex; align-items: center; gap: 10px; margin: 2px 0; color: var(--sg-muted, #94a3b8); font-size: 12px; }
|
|
3898
|
-
.auth__or::before, .auth__or::after { content: ''; flex: 1; height: 1px; background: var(--sg-border, #e6e8ec); }
|
|
3899
|
-
.auth__oauth { display: flex; flex-direction: column; gap: 8px; }
|
|
3900
|
-
.auth__oauth-btn { display: block; text-align: center; padding: 9px; font-size: 13.5px; font-weight: 600; color: var(--sg-fg, #0f172a); background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 9px; text-decoration: none; }
|
|
3901
|
-
.auth__oauth-btn:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 4%, var(--sg-bg, #fff)); }
|
|
3881
|
+
const authStyle = `<style>
|
|
3882
|
+
.auth { display: grid; place-items: center; min-height: 100vh; padding: 24px; background: var(--sg-bg-subtle, #f8fafc); }
|
|
3883
|
+
.auth__card { width: 100%; max-width: 360px; display: flex; flex-direction: column; gap: 12px; padding: 28px; background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 16px; box-shadow: 0 12px 40px -12px rgba(15, 23, 42, 0.18); }
|
|
3884
|
+
.auth__title { margin: 0; font-size: 22px; font-weight: 720; letter-spacing: -0.02em; }
|
|
3885
|
+
.auth__sub { margin: -6px 0 6px; font-size: 13.5px; color: var(--sg-muted, #64748b); }
|
|
3886
|
+
.auth__field { display: flex; flex-direction: column; gap: 5px; font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); }
|
|
3887
|
+
.auth__field input { padding: 9px 11px; font: inherit; font-size: 14px; font-weight: 400; color: var(--sg-fg, #0f172a); background: var(--sg-input-bg, #fff); border: 1px solid var(--sg-input-border, #e6e8ec); border-radius: 9px; }
|
|
3888
|
+
.auth__field input:focus { outline: none; border-color: var(--sg-accent, #6366f1); box-shadow: 0 0 0 3px color-mix(in srgb, var(--sg-accent, #6366f1) 18%, transparent); }
|
|
3889
|
+
.auth__btn { margin-top: 4px; padding: 10px; font: inherit; font-size: 14px; font-weight: 640; color: var(--sg-on-accent, #fff); background: var(--sg-accent, #6366f1); border: none; border-radius: 9px; cursor: pointer; }
|
|
3890
|
+
.auth__btn:hover { filter: brightness(1.06); }
|
|
3891
|
+
.auth__err { margin: 0; padding: 8px 11px; font-size: 13px; color: var(--sg-danger, #b3261e); background: color-mix(in srgb, var(--sg-danger, #dc2626) 9%, var(--sg-bg, #fff)); border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 35%, var(--sg-border, #e6e8ec)); border-radius: 9px; }
|
|
3892
|
+
.auth__ok { margin: 0; padding: 8px 11px; font-size: 13px; color: #166534; background: color-mix(in srgb, #16a34a 9%, var(--sg-bg, #fff)); border: 1px solid color-mix(in srgb, #16a34a 35%, var(--sg-border, #e6e8ec)); border-radius: 9px; }
|
|
3893
|
+
.auth__hint { margin: 6px 0 0; font-size: 12px; color: var(--sg-muted, #94a3b8); text-align: center; }
|
|
3894
|
+
.auth__hint code { background: color-mix(in srgb, var(--sg-fg, #0f172a) 6%, transparent); padding: 1px 5px; border-radius: 5px; }
|
|
3895
|
+
.auth__links { margin: 2px 0 0; display: flex; justify-content: space-between; font-size: 12.5px; }
|
|
3896
|
+
.auth__links a { color: var(--sg-accent, #6366f1); text-decoration: none; font-weight: 600; }
|
|
3897
|
+
.auth__or { display: flex; align-items: center; gap: 10px; margin: 2px 0; color: var(--sg-muted, #94a3b8); font-size: 12px; }
|
|
3898
|
+
.auth__or::before, .auth__or::after { content: ''; flex: 1; height: 1px; background: var(--sg-border, #e6e8ec); }
|
|
3899
|
+
.auth__oauth { display: flex; flex-direction: column; gap: 8px; }
|
|
3900
|
+
.auth__oauth-btn { display: block; text-align: center; padding: 9px; font-size: 13.5px; font-weight: 600; color: var(--sg-fg, #0f172a); background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 9px; text-decoration: none; }
|
|
3901
|
+
.auth__oauth-btn:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 4%, var(--sg-bg, #fff)); }
|
|
3902
3902
|
</style>`;
|
|
3903
|
-
const loginPage = `<script lang="ts">
|
|
3904
|
-
import { enhance } from '$app/forms'
|
|
3905
|
-
let { form } = $props()
|
|
3906
|
-
</script>
|
|
3907
|
-
|
|
3908
|
-
<div class="auth">
|
|
3909
|
-
<form method="POST" use:enhance class="auth__card">
|
|
3910
|
-
<h1 class="auth__title">Sign in</h1>
|
|
3911
|
-
<p class="auth__sub">${jsStrHtml(project.title || 'Welcome back')}</p>
|
|
3912
|
-
{#if form?.error}<p class="auth__err" role="alert">{form.error}</p>{/if}
|
|
3913
|
-
<label class="auth__field"><span>Email</span>
|
|
3914
|
-
<input name="email" type="email" autocomplete="username" value={form?.email ?? ''} required />
|
|
3915
|
-
</label>
|
|
3916
|
-
<label class="auth__field"><span>Password</span>
|
|
3917
|
-
<input name="password" type="password" autocomplete="current-password" required />
|
|
3918
|
-
</label>
|
|
3919
|
-
<button class="auth__btn" type="submit">Sign in</button>${oauthProviders.length ? `
|
|
3920
|
-
<div class="auth__or"><span>or</span></div>
|
|
3921
|
-
<div class="auth__oauth">
|
|
3922
|
-
${oauthProviders.map((p) => `<a class="auth__oauth-btn" href="/auth/${p}">Continue with ${{ github: 'GitHub', google: 'Google', oidc: 'SSO' }[p]}</a>`).join('\n ')}
|
|
3923
|
-
</div>` : ''}
|
|
3924
|
-
<p class="auth__hint">Demo account: <code>${demo.email}</code> / <code>${demo.password}</code></p>${register ? `
|
|
3925
|
-
<p class="auth__links"><a href="/forgot-password">Forgot password?</a><a href="/register">Create account</a></p>` : ''}
|
|
3926
|
-
</form>
|
|
3927
|
-
</div>
|
|
3928
|
-
|
|
3929
|
-
${authStyle}
|
|
3903
|
+
const loginPage = `<script lang="ts">
|
|
3904
|
+
import { enhance } from '$app/forms'
|
|
3905
|
+
let { form } = $props()
|
|
3906
|
+
</script>
|
|
3907
|
+
|
|
3908
|
+
<div class="auth">
|
|
3909
|
+
<form method="POST" use:enhance class="auth__card">
|
|
3910
|
+
<h1 class="auth__title">Sign in</h1>
|
|
3911
|
+
<p class="auth__sub">${jsStrHtml(project.title || 'Welcome back')}</p>
|
|
3912
|
+
{#if form?.error}<p class="auth__err" role="alert">{form.error}</p>{/if}
|
|
3913
|
+
<label class="auth__field"><span>Email</span>
|
|
3914
|
+
<input name="email" type="email" autocomplete="username" value={form?.email ?? ''} required />
|
|
3915
|
+
</label>
|
|
3916
|
+
<label class="auth__field"><span>Password</span>
|
|
3917
|
+
<input name="password" type="password" autocomplete="current-password" required />
|
|
3918
|
+
</label>
|
|
3919
|
+
<button class="auth__btn" type="submit">Sign in</button>${oauthProviders.length ? `
|
|
3920
|
+
<div class="auth__or"><span>or</span></div>
|
|
3921
|
+
<div class="auth__oauth">
|
|
3922
|
+
${oauthProviders.map((p) => `<a class="auth__oauth-btn" href="/auth/${p}">Continue with ${{ github: 'GitHub', google: 'Google', oidc: 'SSO' }[p]}</a>`).join('\n ')}
|
|
3923
|
+
</div>` : ''}
|
|
3924
|
+
<p class="auth__hint">Demo account: <code>${demo.email}</code> / <code>${demo.password}</code></p>${register ? `
|
|
3925
|
+
<p class="auth__links"><a href="/forgot-password">Forgot password?</a><a href="/register">Create account</a></p>` : ''}
|
|
3926
|
+
</form>
|
|
3927
|
+
</div>
|
|
3928
|
+
|
|
3929
|
+
${authStyle}
|
|
3930
3930
|
`;
|
|
3931
|
-
const logoutServerTs = `import { redirect } from '@sveltejs/kit'
|
|
3932
|
-
import type { Actions } from './$types'
|
|
3933
|
-
import { SESSION_COOKIE } from '../lib/server/auth'
|
|
3934
|
-
|
|
3935
|
-
export const actions: Actions = {
|
|
3936
|
-
default: async ({ cookies }) => {
|
|
3937
|
-
cookies.delete(SESSION_COOKIE, { path: '/' })
|
|
3938
|
-
throw redirect(302, '/login')
|
|
3939
|
-
},
|
|
3940
|
-
}
|
|
3931
|
+
const logoutServerTs = `import { redirect } from '@sveltejs/kit'
|
|
3932
|
+
import type { Actions } from './$types'
|
|
3933
|
+
import { SESSION_COOKIE } from '../lib/server/auth'
|
|
3934
|
+
|
|
3935
|
+
export const actions: Actions = {
|
|
3936
|
+
default: async ({ cookies }) => {
|
|
3937
|
+
cookies.delete(SESSION_COOKIE, { path: '/' })
|
|
3938
|
+
throw redirect(302, '/login')
|
|
3939
|
+
},
|
|
3940
|
+
}
|
|
3941
3941
|
`;
|
|
3942
3942
|
// --- change password (any signed-in user; DB-backed only) ---
|
|
3943
|
-
const accountServerTs = `import { fail, redirect } from '@sveltejs/kit'
|
|
3944
|
-
import type { Actions, PageServerLoad } from './$types'
|
|
3945
|
-
import { findUser, updatePassword${twoFactor ? ', setTwoFactor' : ''} } from '$lib/server/users'
|
|
3946
|
-
import { verifyPassword } from '../lib/server/auth'
|
|
3947
|
-
|
|
3948
|
-
export const load: PageServerLoad = async ({ locals }) => {
|
|
3949
|
-
if (!locals.user) throw redirect(302, '/login')
|
|
3950
|
-
${twoFactor ? ` const me = await findUser(locals.user.email)
|
|
3951
|
-
return { user: locals.user, twoFactor: me?.twoFactor ?? false }` : ' return { user: locals.user }'}
|
|
3952
|
-
}
|
|
3953
|
-
|
|
3954
|
-
export const actions: Actions = {
|
|
3955
|
-
default: async ({ request, locals }) => {
|
|
3956
|
-
if (!locals.user) throw redirect(302, '/login')
|
|
3957
|
-
const form = await request.formData()
|
|
3958
|
-
const current = String(form.get('current') ?? '')
|
|
3959
|
-
const next = String(form.get('next') ?? '')
|
|
3960
|
-
if (next.length < 8) return fail(400, { error: 'New password must be at least 8 characters.' })
|
|
3961
|
-
const user = await findUser(locals.user.email)
|
|
3962
|
-
if (!user || !(await verifyPassword(current, user.passwordHash))) return fail(401, { error: 'Current password is incorrect.' })
|
|
3963
|
-
await updatePassword(user.email, next)
|
|
3964
|
-
return { ok: true }
|
|
3965
|
-
},${twoFactor ? `
|
|
3966
|
-
tfa: async ({ request, locals }) => {
|
|
3967
|
-
if (!locals.user) throw redirect(302, '/login')
|
|
3968
|
-
const on = String((await request.formData()).get('on') ?? '') === 'true'
|
|
3969
|
-
await setTwoFactor(locals.user.email, on)
|
|
3970
|
-
return { tfa: on }
|
|
3971
|
-
},` : ''}
|
|
3972
|
-
}
|
|
3943
|
+
const accountServerTs = `import { fail, redirect } from '@sveltejs/kit'
|
|
3944
|
+
import type { Actions, PageServerLoad } from './$types'
|
|
3945
|
+
import { findUser, updatePassword${twoFactor ? ', setTwoFactor' : ''} } from '$lib/server/users'
|
|
3946
|
+
import { verifyPassword } from '../lib/server/auth'
|
|
3947
|
+
|
|
3948
|
+
export const load: PageServerLoad = async ({ locals }) => {
|
|
3949
|
+
if (!locals.user) throw redirect(302, '/login')
|
|
3950
|
+
${twoFactor ? ` const me = await findUser(locals.user.email)
|
|
3951
|
+
return { user: locals.user, twoFactor: me?.twoFactor ?? false }` : ' return { user: locals.user }'}
|
|
3952
|
+
}
|
|
3953
|
+
|
|
3954
|
+
export const actions: Actions = {
|
|
3955
|
+
default: async ({ request, locals }) => {
|
|
3956
|
+
if (!locals.user) throw redirect(302, '/login')
|
|
3957
|
+
const form = await request.formData()
|
|
3958
|
+
const current = String(form.get('current') ?? '')
|
|
3959
|
+
const next = String(form.get('next') ?? '')
|
|
3960
|
+
if (next.length < 8) return fail(400, { error: 'New password must be at least 8 characters.' })
|
|
3961
|
+
const user = await findUser(locals.user.email)
|
|
3962
|
+
if (!user || !(await verifyPassword(current, user.passwordHash))) return fail(401, { error: 'Current password is incorrect.' })
|
|
3963
|
+
await updatePassword(user.email, next)
|
|
3964
|
+
return { ok: true }
|
|
3965
|
+
},${twoFactor ? `
|
|
3966
|
+
tfa: async ({ request, locals }) => {
|
|
3967
|
+
if (!locals.user) throw redirect(302, '/login')
|
|
3968
|
+
const on = String((await request.formData()).get('on') ?? '') === 'true'
|
|
3969
|
+
await setTwoFactor(locals.user.email, on)
|
|
3970
|
+
return { tfa: on }
|
|
3971
|
+
},` : ''}
|
|
3972
|
+
}
|
|
3973
3973
|
`;
|
|
3974
|
-
const accountPage = `<script lang="ts">
|
|
3975
|
-
import { enhance } from '$app/forms'
|
|
3976
|
-
let { data, form } = $props()
|
|
3977
|
-
</script>
|
|
3978
|
-
|
|
3979
|
-
<h1 class="st__title">Account</h1>
|
|
3980
|
-
<div class="acct">
|
|
3981
|
-
<p class="st__sub">Signed in as <strong>{data.user.name}</strong> ({data.user.email}).</p>
|
|
3982
|
-
<form method="POST" use:enhance class="acct__form">
|
|
3983
|
-
<h2 class="acct__h">Change password</h2>
|
|
3984
|
-
{#if form?.ok}<p class="acct__ok">Password updated.</p>{/if}
|
|
3985
|
-
{#if form?.error}<p class="acct__err" role="alert">{form.error}</p>{/if}
|
|
3986
|
-
<label class="acct__field"><span>Current password</span><input name="current" type="password" autocomplete="current-password" required /></label>
|
|
3987
|
-
<label class="acct__field"><span>New password</span><input name="next" type="password" autocomplete="new-password" minlength="8" required /></label>
|
|
3988
|
-
<button class="acct__btn" type="submit">Update password</button>
|
|
3989
|
-
</form>${twoFactor ? `
|
|
3990
|
-
<form method="POST" action="?/tfa" use:enhance class="acct__form">
|
|
3991
|
-
<h2 class="acct__h">Two-factor authentication</h2>
|
|
3992
|
-
<p class="st-hint">{data.twoFactor ? 'Enabled - a code is emailed to you at sign-in.' : 'Add an emailed one-time code at sign-in.'}</p>
|
|
3993
|
-
<input type="hidden" name="on" value={data.twoFactor ? 'false' : 'true'} />
|
|
3994
|
-
<button class="acct__btn" type="submit">{data.twoFactor ? 'Disable' : 'Enable'} two-factor</button>
|
|
3995
|
-
</form>` : ''}
|
|
3996
|
-
</div>
|
|
3997
|
-
|
|
3998
|
-
<style>
|
|
3999
|
-
.acct { max-width: 420px; margin-top: 12px; }
|
|
4000
|
-
.acct__form { display: flex; flex-direction: column; gap: 10px; margin-top: 16px; padding: 18px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); }
|
|
4001
|
-
.acct__h { margin: 0; font-size: 15px; }
|
|
4002
|
-
.acct__field { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); }
|
|
4003
|
-
.acct__field input { padding: 8px 10px; font: inherit; font-size: 14px; font-weight: 400; border: 1px solid var(--sg-input-border, #e6e8ec); border-radius: 8px; background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
|
|
4004
|
-
.acct__btn { align-self: start; margin-top: 4px; padding: 8px 16px; font: inherit; font-size: 13.5px; font-weight: 620; color: var(--sg-on-accent, #fff); background: var(--sg-accent, #6366f1); border: none; border-radius: 8px; cursor: pointer; }
|
|
4005
|
-
.acct__ok { margin: 0; color: #166534; font-size: 13px; }
|
|
4006
|
-
.acct__err { margin: 0; color: var(--sg-danger, #b3261e); font-size: 13px; }
|
|
4007
|
-
</style>
|
|
3974
|
+
const accountPage = `<script lang="ts">
|
|
3975
|
+
import { enhance } from '$app/forms'
|
|
3976
|
+
let { data, form } = $props()
|
|
3977
|
+
</script>
|
|
3978
|
+
|
|
3979
|
+
<h1 class="st__title">Account</h1>
|
|
3980
|
+
<div class="acct">
|
|
3981
|
+
<p class="st__sub">Signed in as <strong>{data.user.name}</strong> ({data.user.email}).</p>
|
|
3982
|
+
<form method="POST" use:enhance class="acct__form">
|
|
3983
|
+
<h2 class="acct__h">Change password</h2>
|
|
3984
|
+
{#if form?.ok}<p class="acct__ok">Password updated.</p>{/if}
|
|
3985
|
+
{#if form?.error}<p class="acct__err" role="alert">{form.error}</p>{/if}
|
|
3986
|
+
<label class="acct__field"><span>Current password</span><input name="current" type="password" autocomplete="current-password" required /></label>
|
|
3987
|
+
<label class="acct__field"><span>New password</span><input name="next" type="password" autocomplete="new-password" minlength="8" required /></label>
|
|
3988
|
+
<button class="acct__btn" type="submit">Update password</button>
|
|
3989
|
+
</form>${twoFactor ? `
|
|
3990
|
+
<form method="POST" action="?/tfa" use:enhance class="acct__form">
|
|
3991
|
+
<h2 class="acct__h">Two-factor authentication</h2>
|
|
3992
|
+
<p class="st-hint">{data.twoFactor ? 'Enabled - a code is emailed to you at sign-in.' : 'Add an emailed one-time code at sign-in.'}</p>
|
|
3993
|
+
<input type="hidden" name="on" value={data.twoFactor ? 'false' : 'true'} />
|
|
3994
|
+
<button class="acct__btn" type="submit">{data.twoFactor ? 'Disable' : 'Enable'} two-factor</button>
|
|
3995
|
+
</form>` : ''}
|
|
3996
|
+
</div>
|
|
3997
|
+
|
|
3998
|
+
<style>
|
|
3999
|
+
.acct { max-width: 420px; margin-top: 12px; }
|
|
4000
|
+
.acct__form { display: flex; flex-direction: column; gap: 10px; margin-top: 16px; padding: 18px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); }
|
|
4001
|
+
.acct__h { margin: 0; font-size: 15px; }
|
|
4002
|
+
.acct__field { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); }
|
|
4003
|
+
.acct__field input { padding: 8px 10px; font: inherit; font-size: 14px; font-weight: 400; border: 1px solid var(--sg-input-border, #e6e8ec); border-radius: 8px; background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
|
|
4004
|
+
.acct__btn { align-self: start; margin-top: 4px; padding: 8px 16px; font: inherit; font-size: 13.5px; font-weight: 620; color: var(--sg-on-accent, #fff); background: var(--sg-accent, #6366f1); border: none; border-radius: 8px; cursor: pointer; }
|
|
4005
|
+
.acct__ok { margin: 0; color: #166534; font-size: 13px; }
|
|
4006
|
+
.acct__err { margin: 0; color: var(--sg-danger, #b3261e); font-size: 13px; }
|
|
4007
|
+
</style>
|
|
4008
4008
|
`;
|
|
4009
4009
|
// --- self-service register + password recovery (register flag) ---
|
|
4010
|
-
const registerServerTs = `import { fail, redirect } from '@sveltejs/kit'
|
|
4011
|
-
import type { Actions, PageServerLoad } from './$types'
|
|
4012
|
-
import { createUser } from '../lib/server/users'
|
|
4013
|
-
import { SESSION_COOKIE, SESSION_MAX_AGE, signSession } from '../lib/server/auth'
|
|
4014
|
-
|
|
4015
|
-
export const load: PageServerLoad = async ({ locals }) => {
|
|
4016
|
-
if (locals.user) throw redirect(302, '/')
|
|
4017
|
-
return {}
|
|
4018
|
-
}
|
|
4019
|
-
|
|
4020
|
-
export const actions: Actions = {
|
|
4021
|
-
default: async ({ request, cookies, url }) => {
|
|
4022
|
-
const form = await request.formData()
|
|
4023
|
-
const email = String(form.get('email') ?? '')
|
|
4024
|
-
const name = String(form.get('name') ?? '')
|
|
4025
|
-
const password = String(form.get('password') ?? '')
|
|
4026
|
-
if (!email.trim() || !name.trim() || password.length < 8) return fail(400, { email, name, error: 'Enter a name, email, and a password of at least 8 characters.' })
|
|
4027
|
-
const user = await createUser({ email, name, password, role: ${JSON.stringify(registerRole)} })
|
|
4028
|
-
if (!user) return fail(409, { email, name, error: 'That email is already registered.' })
|
|
4029
|
-
const token = await signSession({ email: user.email, name: user.name, role: user.role })
|
|
4030
|
-
cookies.set(SESSION_COOKIE, token, { path: '/', httpOnly: true, sameSite: 'lax', secure: url.hostname !== 'localhost' && url.hostname !== '127.0.0.1', maxAge: SESSION_MAX_AGE })
|
|
4031
|
-
throw redirect(302, '/')
|
|
4032
|
-
},
|
|
4033
|
-
}
|
|
4010
|
+
const registerServerTs = `import { fail, redirect } from '@sveltejs/kit'
|
|
4011
|
+
import type { Actions, PageServerLoad } from './$types'
|
|
4012
|
+
import { createUser } from '../lib/server/users'
|
|
4013
|
+
import { SESSION_COOKIE, SESSION_MAX_AGE, signSession } from '../lib/server/auth'
|
|
4014
|
+
|
|
4015
|
+
export const load: PageServerLoad = async ({ locals }) => {
|
|
4016
|
+
if (locals.user) throw redirect(302, '/')
|
|
4017
|
+
return {}
|
|
4018
|
+
}
|
|
4019
|
+
|
|
4020
|
+
export const actions: Actions = {
|
|
4021
|
+
default: async ({ request, cookies, url }) => {
|
|
4022
|
+
const form = await request.formData()
|
|
4023
|
+
const email = String(form.get('email') ?? '')
|
|
4024
|
+
const name = String(form.get('name') ?? '')
|
|
4025
|
+
const password = String(form.get('password') ?? '')
|
|
4026
|
+
if (!email.trim() || !name.trim() || password.length < 8) return fail(400, { email, name, error: 'Enter a name, email, and a password of at least 8 characters.' })
|
|
4027
|
+
const user = await createUser({ email, name, password, role: ${JSON.stringify(registerRole)} })
|
|
4028
|
+
if (!user) return fail(409, { email, name, error: 'That email is already registered.' })
|
|
4029
|
+
const token = await signSession({ email: user.email, name: user.name, role: user.role })
|
|
4030
|
+
cookies.set(SESSION_COOKIE, token, { path: '/', httpOnly: true, sameSite: 'lax', secure: url.hostname !== 'localhost' && url.hostname !== '127.0.0.1', maxAge: SESSION_MAX_AGE })
|
|
4031
|
+
throw redirect(302, '/')
|
|
4032
|
+
},
|
|
4033
|
+
}
|
|
4034
4034
|
`;
|
|
4035
|
-
const registerPage = `<script lang="ts">
|
|
4036
|
-
import { enhance } from '$app/forms'
|
|
4037
|
-
let { form } = $props()
|
|
4038
|
-
</script>
|
|
4039
|
-
|
|
4040
|
-
<div class="auth">
|
|
4041
|
-
<form method="POST" use:enhance class="auth__card">
|
|
4042
|
-
<h1 class="auth__title">Create account</h1>
|
|
4043
|
-
<p class="auth__sub">${jsStrHtml(project.title || '')}</p>
|
|
4044
|
-
{#if form?.error}<p class="auth__err" role="alert">{form.error}</p>{/if}
|
|
4045
|
-
<label class="auth__field"><span>Name</span><input name="name" autocomplete="name" value={form?.name ?? ''} required /></label>
|
|
4046
|
-
<label class="auth__field"><span>Email</span><input name="email" type="email" autocomplete="username" value={form?.email ?? ''} required /></label>
|
|
4047
|
-
<label class="auth__field"><span>Password</span><input name="password" type="password" autocomplete="new-password" minlength="8" required /></label>
|
|
4048
|
-
<button class="auth__btn" type="submit">Create account</button>
|
|
4049
|
-
<p class="auth__links"><a href="/login">Have an account? Sign in</a></p>
|
|
4050
|
-
</form>
|
|
4051
|
-
</div>
|
|
4052
|
-
|
|
4053
|
-
${authStyle}
|
|
4035
|
+
const registerPage = `<script lang="ts">
|
|
4036
|
+
import { enhance } from '$app/forms'
|
|
4037
|
+
let { form } = $props()
|
|
4038
|
+
</script>
|
|
4039
|
+
|
|
4040
|
+
<div class="auth">
|
|
4041
|
+
<form method="POST" use:enhance class="auth__card">
|
|
4042
|
+
<h1 class="auth__title">Create account</h1>
|
|
4043
|
+
<p class="auth__sub">${jsStrHtml(project.title || '')}</p>
|
|
4044
|
+
{#if form?.error}<p class="auth__err" role="alert">{form.error}</p>{/if}
|
|
4045
|
+
<label class="auth__field"><span>Name</span><input name="name" autocomplete="name" value={form?.name ?? ''} required /></label>
|
|
4046
|
+
<label class="auth__field"><span>Email</span><input name="email" type="email" autocomplete="username" value={form?.email ?? ''} required /></label>
|
|
4047
|
+
<label class="auth__field"><span>Password</span><input name="password" type="password" autocomplete="new-password" minlength="8" required /></label>
|
|
4048
|
+
<button class="auth__btn" type="submit">Create account</button>
|
|
4049
|
+
<p class="auth__links"><a href="/login">Have an account? Sign in</a></p>
|
|
4050
|
+
</form>
|
|
4051
|
+
</div>
|
|
4052
|
+
|
|
4053
|
+
${authStyle}
|
|
4054
4054
|
`;
|
|
4055
4055
|
const forgotSend = emailReal
|
|
4056
|
-
? `if (user) {
|
|
4057
|
-
const link = url.origin + '/reset-password?token=' + encodeURIComponent(await signReset(user.email))
|
|
4058
|
-
await sendEmail(user.email, 'Reset your password', '<p>Reset your password with the link below (expires in 30 minutes):</p><p><a href="' + link + '">' + link + '</a></p>')
|
|
4056
|
+
? `if (user) {
|
|
4057
|
+
const link = url.origin + '/reset-password?token=' + encodeURIComponent(await signReset(user.email))
|
|
4058
|
+
await sendEmail(user.email, 'Reset your password', '<p>Reset your password with the link below (expires in 30 minutes):</p><p><a href="' + link + '">' + link + '</a></p>')
|
|
4059
4059
|
}`
|
|
4060
4060
|
: `if (user) await sendResetEmail(user.email, url.origin + '/reset-password?token=' + encodeURIComponent(await signReset(user.email)))`;
|
|
4061
|
-
const forgotServerTs = `import type { Actions } from './$types'
|
|
4062
|
-
import { findUser } from '../lib/server/users'
|
|
4063
|
-
import { signReset${emailReal ? '' : ', sendResetEmail'} } from '$lib/server/auth'${emailReal ? "\nimport { sendEmail } from '$lib/server/email'" : ''}
|
|
4064
|
-
|
|
4065
|
-
export const actions: Actions = {
|
|
4066
|
-
default: async ({ request, url }) => {
|
|
4067
|
-
const email = String((await request.formData()).get('email') ?? '').trim().toLowerCase()
|
|
4068
|
-
const user = await findUser(email)
|
|
4069
|
-
// Respond identically whether or not the account exists (no account enumeration).
|
|
4070
|
-
${forgotSend}
|
|
4071
|
-
return { sent: true }
|
|
4072
|
-
},
|
|
4073
|
-
}
|
|
4061
|
+
const forgotServerTs = `import type { Actions } from './$types'
|
|
4062
|
+
import { findUser } from '../lib/server/users'
|
|
4063
|
+
import { signReset${emailReal ? '' : ', sendResetEmail'} } from '$lib/server/auth'${emailReal ? "\nimport { sendEmail } from '$lib/server/email'" : ''}
|
|
4064
|
+
|
|
4065
|
+
export const actions: Actions = {
|
|
4066
|
+
default: async ({ request, url }) => {
|
|
4067
|
+
const email = String((await request.formData()).get('email') ?? '').trim().toLowerCase()
|
|
4068
|
+
const user = await findUser(email)
|
|
4069
|
+
// Respond identically whether or not the account exists (no account enumeration).
|
|
4070
|
+
${forgotSend}
|
|
4071
|
+
return { sent: true }
|
|
4072
|
+
},
|
|
4073
|
+
}
|
|
4074
4074
|
`;
|
|
4075
|
-
const forgotPage = `<script lang="ts">
|
|
4076
|
-
import { enhance } from '$app/forms'
|
|
4077
|
-
let { form } = $props()
|
|
4078
|
-
</script>
|
|
4079
|
-
|
|
4080
|
-
<div class="auth">
|
|
4081
|
-
<form method="POST" use:enhance class="auth__card">
|
|
4082
|
-
<h1 class="auth__title">Reset password</h1>
|
|
4083
|
-
<p class="auth__sub">We'll email you a reset link.</p>
|
|
4084
|
-
{#if form?.sent}<p class="auth__ok">If that account exists, a reset link is on its way. (Dev: the link is logged to the server console.)</p>{/if}
|
|
4085
|
-
<label class="auth__field"><span>Email</span><input name="email" type="email" autocomplete="username" required /></label>
|
|
4086
|
-
<button class="auth__btn" type="submit">Send reset link</button>
|
|
4087
|
-
<p class="auth__links"><a href="/login">Back to sign in</a></p>
|
|
4088
|
-
</form>
|
|
4089
|
-
</div>
|
|
4090
|
-
|
|
4091
|
-
${authStyle}
|
|
4075
|
+
const forgotPage = `<script lang="ts">
|
|
4076
|
+
import { enhance } from '$app/forms'
|
|
4077
|
+
let { form } = $props()
|
|
4078
|
+
</script>
|
|
4079
|
+
|
|
4080
|
+
<div class="auth">
|
|
4081
|
+
<form method="POST" use:enhance class="auth__card">
|
|
4082
|
+
<h1 class="auth__title">Reset password</h1>
|
|
4083
|
+
<p class="auth__sub">We'll email you a reset link.</p>
|
|
4084
|
+
{#if form?.sent}<p class="auth__ok">If that account exists, a reset link is on its way. (Dev: the link is logged to the server console.)</p>{/if}
|
|
4085
|
+
<label class="auth__field"><span>Email</span><input name="email" type="email" autocomplete="username" required /></label>
|
|
4086
|
+
<button class="auth__btn" type="submit">Send reset link</button>
|
|
4087
|
+
<p class="auth__links"><a href="/login">Back to sign in</a></p>
|
|
4088
|
+
</form>
|
|
4089
|
+
</div>
|
|
4090
|
+
|
|
4091
|
+
${authStyle}
|
|
4092
4092
|
`;
|
|
4093
|
-
const resetServerTs = `import { fail, redirect } from '@sveltejs/kit'
|
|
4094
|
-
import type { Actions, PageServerLoad } from './$types'
|
|
4095
|
-
import { readReset } from '../lib/server/auth'
|
|
4096
|
-
import { updatePassword } from '../lib/server/users'
|
|
4097
|
-
|
|
4098
|
-
export const load: PageServerLoad = async ({ url }) => {
|
|
4099
|
-
return { valid: !!(await readReset(url.searchParams.get('token') ?? undefined)) }
|
|
4100
|
-
}
|
|
4101
|
-
|
|
4102
|
-
export const actions: Actions = {
|
|
4103
|
-
default: async ({ request, url }) => {
|
|
4104
|
-
const email = await readReset(url.searchParams.get('token') ?? undefined)
|
|
4105
|
-
if (!email) return fail(400, { error: 'This reset link is invalid or has expired.' })
|
|
4106
|
-
const next = String((await request.formData()).get('password') ?? '')
|
|
4107
|
-
if (next.length < 8) return fail(400, { error: 'Password must be at least 8 characters.' })
|
|
4108
|
-
await updatePassword(email, next)
|
|
4109
|
-
throw redirect(302, '/login')
|
|
4110
|
-
},
|
|
4111
|
-
}
|
|
4093
|
+
const resetServerTs = `import { fail, redirect } from '@sveltejs/kit'
|
|
4094
|
+
import type { Actions, PageServerLoad } from './$types'
|
|
4095
|
+
import { readReset } from '../lib/server/auth'
|
|
4096
|
+
import { updatePassword } from '../lib/server/users'
|
|
4097
|
+
|
|
4098
|
+
export const load: PageServerLoad = async ({ url }) => {
|
|
4099
|
+
return { valid: !!(await readReset(url.searchParams.get('token') ?? undefined)) }
|
|
4100
|
+
}
|
|
4101
|
+
|
|
4102
|
+
export const actions: Actions = {
|
|
4103
|
+
default: async ({ request, url }) => {
|
|
4104
|
+
const email = await readReset(url.searchParams.get('token') ?? undefined)
|
|
4105
|
+
if (!email) return fail(400, { error: 'This reset link is invalid or has expired.' })
|
|
4106
|
+
const next = String((await request.formData()).get('password') ?? '')
|
|
4107
|
+
if (next.length < 8) return fail(400, { error: 'Password must be at least 8 characters.' })
|
|
4108
|
+
await updatePassword(email, next)
|
|
4109
|
+
throw redirect(302, '/login')
|
|
4110
|
+
},
|
|
4111
|
+
}
|
|
4112
4112
|
`;
|
|
4113
|
-
const resetPage = `<script lang="ts">
|
|
4114
|
-
import { enhance } from '$app/forms'
|
|
4115
|
-
let { data, form } = $props()
|
|
4116
|
-
</script>
|
|
4117
|
-
|
|
4118
|
-
<div class="auth">
|
|
4119
|
-
<form method="POST" use:enhance class="auth__card">
|
|
4120
|
-
<h1 class="auth__title">Set a new password</h1>
|
|
4121
|
-
{#if !data.valid}
|
|
4122
|
-
<p class="auth__err">This reset link is invalid or has expired. <a href="/forgot-password">Request a new one</a>.</p>
|
|
4123
|
-
{:else}
|
|
4124
|
-
{#if form?.error}<p class="auth__err" role="alert">{form.error}</p>{/if}
|
|
4125
|
-
<label class="auth__field"><span>New password</span><input name="password" type="password" autocomplete="new-password" minlength="8" required /></label>
|
|
4126
|
-
<button class="auth__btn" type="submit">Update password</button>
|
|
4127
|
-
{/if}
|
|
4128
|
-
<p class="auth__links"><a href="/login">Back to sign in</a></p>
|
|
4129
|
-
</form>
|
|
4130
|
-
</div>
|
|
4131
|
-
|
|
4132
|
-
${authStyle}
|
|
4113
|
+
const resetPage = `<script lang="ts">
|
|
4114
|
+
import { enhance } from '$app/forms'
|
|
4115
|
+
let { data, form } = $props()
|
|
4116
|
+
</script>
|
|
4117
|
+
|
|
4118
|
+
<div class="auth">
|
|
4119
|
+
<form method="POST" use:enhance class="auth__card">
|
|
4120
|
+
<h1 class="auth__title">Set a new password</h1>
|
|
4121
|
+
{#if !data.valid}
|
|
4122
|
+
<p class="auth__err">This reset link is invalid or has expired. <a href="/forgot-password">Request a new one</a>.</p>
|
|
4123
|
+
{:else}
|
|
4124
|
+
{#if form?.error}<p class="auth__err" role="alert">{form.error}</p>{/if}
|
|
4125
|
+
<label class="auth__field"><span>New password</span><input name="password" type="password" autocomplete="new-password" minlength="8" required /></label>
|
|
4126
|
+
<button class="auth__btn" type="submit">Update password</button>
|
|
4127
|
+
{/if}
|
|
4128
|
+
<p class="auth__links"><a href="/login">Back to sign in</a></p>
|
|
4129
|
+
</form>
|
|
4130
|
+
</div>
|
|
4131
|
+
|
|
4132
|
+
${authStyle}
|
|
4133
4133
|
`;
|
|
4134
4134
|
// --- admin user management (userAdmin flag: DB-backed + RBAC) ---
|
|
4135
|
-
const usersServerTs = `import { fail, redirect } from '@sveltejs/kit'
|
|
4136
|
-
import type { Actions, PageServerLoad, RequestEvent } from './$types'
|
|
4137
|
-
import { getServerRole, canScreen, ROLES } from '../lib/access'
|
|
4138
|
-
import { listUsers, createUser, setUserRole, deleteUser } from '../lib/server/users'
|
|
4139
|
-
|
|
4140
|
-
// Only a full-access role (screens: '*') may manage users.
|
|
4141
|
-
function guard(event: RequestEvent): void {
|
|
4142
|
-
if (!canScreen(getServerRole(event), '__users__')) throw redirect(302, '/')
|
|
4143
|
-
}
|
|
4144
|
-
|
|
4145
|
-
export const load: PageServerLoad = async (event) => {
|
|
4146
|
-
guard(event)
|
|
4147
|
-
return { users: await listUsers(), roles: ROLES }
|
|
4148
|
-
}
|
|
4149
|
-
|
|
4150
|
-
export const actions: Actions = {
|
|
4151
|
-
create: async (event) => {
|
|
4152
|
-
guard(event)
|
|
4153
|
-
const form = await event.request.formData()
|
|
4154
|
-
const email = String(form.get('email') ?? '')
|
|
4155
|
-
const name = String(form.get('name') ?? '')
|
|
4156
|
-
const password = String(form.get('password') ?? '')
|
|
4157
|
-
const role = String(form.get('role') ?? ROLES[0])
|
|
4158
|
-
if (!email.trim() || !name.trim() || password.length < 8) return fail(400, { error: 'Name, email, and an 8+ character password are required.' })
|
|
4159
|
-
if (!(await createUser({ email, name, password, role }))) return fail(409, { error: 'That email already exists.' })
|
|
4160
|
-
return { ok: 'created' }
|
|
4161
|
-
},
|
|
4162
|
-
role: async (event) => {
|
|
4163
|
-
guard(event)
|
|
4164
|
-
const form = await event.request.formData()
|
|
4165
|
-
await setUserRole(String(form.get('email') ?? ''), String(form.get('role') ?? ''))
|
|
4166
|
-
return { ok: 'updated' }
|
|
4167
|
-
},
|
|
4168
|
-
remove: async (event) => {
|
|
4169
|
-
guard(event)
|
|
4170
|
-
await deleteUser(String((await event.request.formData()).get('email') ?? ''))
|
|
4171
|
-
return { ok: 'removed' }
|
|
4172
|
-
},
|
|
4173
|
-
}
|
|
4135
|
+
const usersServerTs = `import { fail, redirect } from '@sveltejs/kit'
|
|
4136
|
+
import type { Actions, PageServerLoad, RequestEvent } from './$types'
|
|
4137
|
+
import { getServerRole, canScreen, ROLES } from '../lib/access'
|
|
4138
|
+
import { listUsers, createUser, setUserRole, deleteUser } from '../lib/server/users'
|
|
4139
|
+
|
|
4140
|
+
// Only a full-access role (screens: '*') may manage users.
|
|
4141
|
+
function guard(event: RequestEvent): void {
|
|
4142
|
+
if (!canScreen(getServerRole(event), '__users__')) throw redirect(302, '/')
|
|
4143
|
+
}
|
|
4144
|
+
|
|
4145
|
+
export const load: PageServerLoad = async (event) => {
|
|
4146
|
+
guard(event)
|
|
4147
|
+
return { users: await listUsers(), roles: ROLES }
|
|
4148
|
+
}
|
|
4149
|
+
|
|
4150
|
+
export const actions: Actions = {
|
|
4151
|
+
create: async (event) => {
|
|
4152
|
+
guard(event)
|
|
4153
|
+
const form = await event.request.formData()
|
|
4154
|
+
const email = String(form.get('email') ?? '')
|
|
4155
|
+
const name = String(form.get('name') ?? '')
|
|
4156
|
+
const password = String(form.get('password') ?? '')
|
|
4157
|
+
const role = String(form.get('role') ?? ROLES[0])
|
|
4158
|
+
if (!email.trim() || !name.trim() || password.length < 8) return fail(400, { error: 'Name, email, and an 8+ character password are required.' })
|
|
4159
|
+
if (!(await createUser({ email, name, password, role }))) return fail(409, { error: 'That email already exists.' })
|
|
4160
|
+
return { ok: 'created' }
|
|
4161
|
+
},
|
|
4162
|
+
role: async (event) => {
|
|
4163
|
+
guard(event)
|
|
4164
|
+
const form = await event.request.formData()
|
|
4165
|
+
await setUserRole(String(form.get('email') ?? ''), String(form.get('role') ?? ''))
|
|
4166
|
+
return { ok: 'updated' }
|
|
4167
|
+
},
|
|
4168
|
+
remove: async (event) => {
|
|
4169
|
+
guard(event)
|
|
4170
|
+
await deleteUser(String((await event.request.formData()).get('email') ?? ''))
|
|
4171
|
+
return { ok: 'removed' }
|
|
4172
|
+
},
|
|
4173
|
+
}
|
|
4174
4174
|
`;
|
|
4175
|
-
const usersPage = `<script lang="ts">
|
|
4176
|
-
import { enhance } from '$app/forms'
|
|
4177
|
-
let { data, form } = $props()
|
|
4178
|
-
</script>
|
|
4179
|
-
|
|
4180
|
-
<h1 class="st__title">Users</h1>
|
|
4181
|
-
{#if form?.error}<p class="usr__msg usr__msg--err" role="alert">{form.error}</p>{/if}
|
|
4182
|
-
<div class="usr">
|
|
4183
|
-
<table class="usr__table">
|
|
4184
|
-
<thead><tr><th>Name</th><th>Email</th><th>Role</th><th></th></tr></thead>
|
|
4185
|
-
<tbody>
|
|
4186
|
-
{#each data.users as u (u.email)}
|
|
4187
|
-
<tr>
|
|
4188
|
-
<td>{u.name}</td>
|
|
4189
|
-
<td>{u.email}</td>
|
|
4190
|
-
<td>
|
|
4191
|
-
<form method="POST" action="?/role" use:enhance>
|
|
4192
|
-
<input type="hidden" name="email" value={u.email} />
|
|
4193
|
-
<select name="role" onchange={(e) => e.currentTarget.form?.requestSubmit()}>
|
|
4194
|
-
{#each data.roles as r (r)}<option value={r} selected={r === u.role}>{r}</option>{/each}
|
|
4195
|
-
</select>
|
|
4196
|
-
</form>
|
|
4197
|
-
</td>
|
|
4198
|
-
<td>
|
|
4199
|
-
<form method="POST" action="?/remove" use:enhance>
|
|
4200
|
-
<input type="hidden" name="email" value={u.email} />
|
|
4201
|
-
<button class="usr__del" type="submit">Remove</button>
|
|
4202
|
-
</form>
|
|
4203
|
-
</td>
|
|
4204
|
-
</tr>
|
|
4205
|
-
{/each}
|
|
4206
|
-
</tbody>
|
|
4207
|
-
</table>
|
|
4208
|
-
|
|
4209
|
-
<form method="POST" action="?/create" use:enhance class="usr__add">
|
|
4210
|
-
<h2 class="usr__h">Add user</h2>
|
|
4211
|
-
<input name="name" placeholder="Name" required />
|
|
4212
|
-
<input name="email" type="email" placeholder="Email" required />
|
|
4213
|
-
<input name="password" type="password" placeholder="Password (8+)" minlength="8" required />
|
|
4214
|
-
<select name="role">{#each data.roles as r (r)}<option value={r}>{r}</option>{/each}</select>
|
|
4215
|
-
<button class="usr__btn" type="submit">Add user</button>
|
|
4216
|
-
</form>
|
|
4217
|
-
</div>
|
|
4218
|
-
|
|
4219
|
-
<style>
|
|
4220
|
-
.usr { display: flex; flex-direction: column; gap: 18px; margin-top: 14px; }
|
|
4221
|
-
.usr__table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
|
|
4222
|
-
.usr__table th { text-align: left; padding: 8px 10px; color: var(--sg-muted, #64748b); font-weight: 600; border-bottom: 1px solid var(--sg-border, #e6e8ec); }
|
|
4223
|
-
.usr__table td { padding: 7px 10px; border-bottom: 1px solid var(--sg-border, #f1f5f9); }
|
|
4224
|
-
.usr__table select { padding: 5px 8px; font: inherit; font-size: 13px; border: 1px solid var(--sg-input-border, #e6e8ec); border-radius: 7px; background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
|
|
4225
|
-
.usr__del { padding: 4px 10px; font: inherit; font-size: 12.5px; color: #dc2626; background: none; border: 1px solid color-mix(in srgb, #dc2626 40%, var(--sg-border, #e6e8ec)); border-radius: 7px; cursor: pointer; }
|
|
4226
|
-
.usr__add { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 16px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); }
|
|
4227
|
-
.usr__h { width: 100%; margin: 0 0 4px; font-size: 15px; }
|
|
4228
|
-
.usr__add input, .usr__add select { padding: 8px 10px; font: inherit; font-size: 13.5px; border: 1px solid var(--sg-input-border, #e6e8ec); border-radius: 8px; background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
|
|
4229
|
-
.usr__btn { padding: 8px 16px; font: inherit; font-size: 13.5px; font-weight: 620; color: var(--sg-on-accent, #fff); background: var(--sg-accent, #6366f1); border: none; border-radius: 8px; cursor: pointer; }
|
|
4230
|
-
.usr__msg { margin: 0 0 10px; padding: 8px 11px; border-radius: 9px; font-size: 13px; }
|
|
4231
|
-
.usr__msg--err { color: var(--sg-danger, #b3261e); background: color-mix(in srgb, var(--sg-danger, #dc2626) 9%, var(--sg-bg, #fff)); border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 35%, var(--sg-border, #e6e8ec)); }
|
|
4232
|
-
</style>
|
|
4175
|
+
const usersPage = `<script lang="ts">
|
|
4176
|
+
import { enhance } from '$app/forms'
|
|
4177
|
+
let { data, form } = $props()
|
|
4178
|
+
</script>
|
|
4179
|
+
|
|
4180
|
+
<h1 class="st__title">Users</h1>
|
|
4181
|
+
{#if form?.error}<p class="usr__msg usr__msg--err" role="alert">{form.error}</p>{/if}
|
|
4182
|
+
<div class="usr">
|
|
4183
|
+
<table class="usr__table">
|
|
4184
|
+
<thead><tr><th>Name</th><th>Email</th><th>Role</th><th></th></tr></thead>
|
|
4185
|
+
<tbody>
|
|
4186
|
+
{#each data.users as u (u.email)}
|
|
4187
|
+
<tr>
|
|
4188
|
+
<td>{u.name}</td>
|
|
4189
|
+
<td>{u.email}</td>
|
|
4190
|
+
<td>
|
|
4191
|
+
<form method="POST" action="?/role" use:enhance>
|
|
4192
|
+
<input type="hidden" name="email" value={u.email} />
|
|
4193
|
+
<select name="role" onchange={(e) => e.currentTarget.form?.requestSubmit()}>
|
|
4194
|
+
{#each data.roles as r (r)}<option value={r} selected={r === u.role}>{r}</option>{/each}
|
|
4195
|
+
</select>
|
|
4196
|
+
</form>
|
|
4197
|
+
</td>
|
|
4198
|
+
<td>
|
|
4199
|
+
<form method="POST" action="?/remove" use:enhance>
|
|
4200
|
+
<input type="hidden" name="email" value={u.email} />
|
|
4201
|
+
<button class="usr__del" type="submit">Remove</button>
|
|
4202
|
+
</form>
|
|
4203
|
+
</td>
|
|
4204
|
+
</tr>
|
|
4205
|
+
{/each}
|
|
4206
|
+
</tbody>
|
|
4207
|
+
</table>
|
|
4208
|
+
|
|
4209
|
+
<form method="POST" action="?/create" use:enhance class="usr__add">
|
|
4210
|
+
<h2 class="usr__h">Add user</h2>
|
|
4211
|
+
<input name="name" placeholder="Name" required />
|
|
4212
|
+
<input name="email" type="email" placeholder="Email" required />
|
|
4213
|
+
<input name="password" type="password" placeholder="Password (8+)" minlength="8" required />
|
|
4214
|
+
<select name="role">{#each data.roles as r (r)}<option value={r}>{r}</option>{/each}</select>
|
|
4215
|
+
<button class="usr__btn" type="submit">Add user</button>
|
|
4216
|
+
</form>
|
|
4217
|
+
</div>
|
|
4218
|
+
|
|
4219
|
+
<style>
|
|
4220
|
+
.usr { display: flex; flex-direction: column; gap: 18px; margin-top: 14px; }
|
|
4221
|
+
.usr__table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
|
|
4222
|
+
.usr__table th { text-align: left; padding: 8px 10px; color: var(--sg-muted, #64748b); font-weight: 600; border-bottom: 1px solid var(--sg-border, #e6e8ec); }
|
|
4223
|
+
.usr__table td { padding: 7px 10px; border-bottom: 1px solid var(--sg-border, #f1f5f9); }
|
|
4224
|
+
.usr__table select { padding: 5px 8px; font: inherit; font-size: 13px; border: 1px solid var(--sg-input-border, #e6e8ec); border-radius: 7px; background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
|
|
4225
|
+
.usr__del { padding: 4px 10px; font: inherit; font-size: 12.5px; color: #dc2626; background: none; border: 1px solid color-mix(in srgb, #dc2626 40%, var(--sg-border, #e6e8ec)); border-radius: 7px; cursor: pointer; }
|
|
4226
|
+
.usr__add { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 16px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); }
|
|
4227
|
+
.usr__h { width: 100%; margin: 0 0 4px; font-size: 15px; }
|
|
4228
|
+
.usr__add input, .usr__add select { padding: 8px 10px; font: inherit; font-size: 13.5px; border: 1px solid var(--sg-input-border, #e6e8ec); border-radius: 8px; background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
|
|
4229
|
+
.usr__btn { padding: 8px 16px; font: inherit; font-size: 13.5px; font-weight: 620; color: var(--sg-on-accent, #fff); background: var(--sg-accent, #6366f1); border: none; border-radius: 8px; cursor: pointer; }
|
|
4230
|
+
.usr__msg { margin: 0 0 10px; padding: 8px 11px; border-radius: 9px; font-size: 13px; }
|
|
4231
|
+
.usr__msg--err { color: var(--sg-danger, #b3261e); background: color-mix(in srgb, var(--sg-danger, #dc2626) 9%, var(--sg-bg, #fff)); border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 35%, var(--sg-border, #e6e8ec)); }
|
|
4232
|
+
</style>
|
|
4233
4233
|
`;
|
|
4234
4234
|
// --- real email (Resend HTTP / SMTP nodemailer / dev console) ---
|
|
4235
|
-
const emailTs = `// Regenerated by SvGrid Studio. Email delivery: Resend (HTTP API, works on edge) or
|
|
4236
|
-
// SMTP (nodemailer, Node runtimes). Logs to the console in dev when neither is set.
|
|
4237
|
-
import { env } from '$env/dynamic/private'
|
|
4238
|
-
|
|
4239
|
-
export async function sendEmail(to: string, subject: string, html: string): Promise<void> {
|
|
4240
|
-
const from = env.EMAIL_FROM || 'onboarding@resend.dev'
|
|
4241
|
-
if (env.RESEND_API_KEY) {
|
|
4242
|
-
const res = await fetch('https://api.resend.com/emails', {
|
|
4243
|
-
method: 'POST',
|
|
4244
|
-
headers: { authorization: 'Bearer ' + env.RESEND_API_KEY, 'content-type': 'application/json' },
|
|
4245
|
-
body: JSON.stringify({ from, to, subject, html }),
|
|
4246
|
-
})
|
|
4247
|
-
if (!res.ok) throw new Error('Resend send failed: ' + res.status + ' ' + (await res.text()))
|
|
4248
|
-
return
|
|
4249
|
-
}
|
|
4250
|
-
if (env.SMTP_HOST) {
|
|
4251
|
-
// Dynamic import so edge builds (Resend, or no email) never bundle nodemailer.
|
|
4252
|
-
const nodemailer = (await import('nodemailer')).default
|
|
4253
|
-
const transport = nodemailer.createTransport({
|
|
4254
|
-
host: env.SMTP_HOST,
|
|
4255
|
-
port: Number(env.SMTP_PORT ?? 587),
|
|
4256
|
-
secure: env.SMTP_SECURE === 'true',
|
|
4257
|
-
auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASS } : undefined,
|
|
4258
|
-
})
|
|
4259
|
-
await transport.sendMail({ from, to, subject, html })
|
|
4260
|
-
return
|
|
4261
|
-
}
|
|
4262
|
-
console.log('[email] (dev - set RESEND_API_KEY or SMTP_* to send) To: ' + to + ' | ' + subject + '\\n' + html)
|
|
4263
|
-
}
|
|
4235
|
+
const emailTs = `// Regenerated by SvGrid Studio. Email delivery: Resend (HTTP API, works on edge) or
|
|
4236
|
+
// SMTP (nodemailer, Node runtimes). Logs to the console in dev when neither is set.
|
|
4237
|
+
import { env } from '$env/dynamic/private'
|
|
4238
|
+
|
|
4239
|
+
export async function sendEmail(to: string, subject: string, html: string): Promise<void> {
|
|
4240
|
+
const from = env.EMAIL_FROM || 'onboarding@resend.dev'
|
|
4241
|
+
if (env.RESEND_API_KEY) {
|
|
4242
|
+
const res = await fetch('https://api.resend.com/emails', {
|
|
4243
|
+
method: 'POST',
|
|
4244
|
+
headers: { authorization: 'Bearer ' + env.RESEND_API_KEY, 'content-type': 'application/json' },
|
|
4245
|
+
body: JSON.stringify({ from, to, subject, html }),
|
|
4246
|
+
})
|
|
4247
|
+
if (!res.ok) throw new Error('Resend send failed: ' + res.status + ' ' + (await res.text()))
|
|
4248
|
+
return
|
|
4249
|
+
}
|
|
4250
|
+
if (env.SMTP_HOST) {
|
|
4251
|
+
// Dynamic import so edge builds (Resend, or no email) never bundle nodemailer.
|
|
4252
|
+
const nodemailer = (await import('nodemailer')).default
|
|
4253
|
+
const transport = nodemailer.createTransport({
|
|
4254
|
+
host: env.SMTP_HOST,
|
|
4255
|
+
port: Number(env.SMTP_PORT ?? 587),
|
|
4256
|
+
secure: env.SMTP_SECURE === 'true',
|
|
4257
|
+
auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASS } : undefined,
|
|
4258
|
+
})
|
|
4259
|
+
await transport.sendMail({ from, to, subject, html })
|
|
4260
|
+
return
|
|
4261
|
+
}
|
|
4262
|
+
console.log('[email] (dev - set RESEND_API_KEY or SMTP_* to send) To: ' + to + ' | ' + subject + '\\n' + html)
|
|
4263
|
+
}
|
|
4264
4264
|
`;
|
|
4265
4265
|
// --- email 2FA verify step ---
|
|
4266
|
-
const verifyServerTs = `import { fail, redirect } from '@sveltejs/kit'
|
|
4267
|
-
import type { Actions, PageServerLoad } from './$types'
|
|
4268
|
-
import { SESSION_COOKIE, SESSION_MAX_AGE, TFA_COOKIE, readChallenge, signSession } from '../lib/server/auth'
|
|
4269
|
-
import { findUser } from '../lib/server/users'
|
|
4270
|
-
|
|
4271
|
-
export const load: PageServerLoad = async ({ cookies }) => {
|
|
4272
|
-
if (!cookies.get(TFA_COOKIE)) throw redirect(302, '/login')
|
|
4273
|
-
return {}
|
|
4274
|
-
}
|
|
4275
|
-
|
|
4276
|
-
export const actions: Actions = {
|
|
4277
|
-
default: async ({ request, cookies, url }) => {
|
|
4278
|
-
const code = String((await request.formData()).get('code') ?? '')
|
|
4279
|
-
const email = await readChallenge(cookies.get(TFA_COOKIE), code)
|
|
4280
|
-
if (!email) return fail(401, { error: 'That code is invalid or has expired.' })
|
|
4281
|
-
const user = await findUser(email)
|
|
4282
|
-
if (!user) return fail(401, { error: 'Account not found.' })
|
|
4283
|
-
cookies.delete(TFA_COOKIE, { path: '/' })
|
|
4284
|
-
cookies.set(SESSION_COOKIE, await signSession({ email: user.email, name: user.name, role: user.role }), {
|
|
4285
|
-
path: '/', httpOnly: true, sameSite: 'lax', secure: url.hostname !== 'localhost' && url.hostname !== '127.0.0.1', maxAge: SESSION_MAX_AGE,
|
|
4286
|
-
})
|
|
4287
|
-
throw redirect(302, url.searchParams.get('redirectTo') || '/')
|
|
4288
|
-
},
|
|
4289
|
-
}
|
|
4266
|
+
const verifyServerTs = `import { fail, redirect } from '@sveltejs/kit'
|
|
4267
|
+
import type { Actions, PageServerLoad } from './$types'
|
|
4268
|
+
import { SESSION_COOKIE, SESSION_MAX_AGE, TFA_COOKIE, readChallenge, signSession } from '../lib/server/auth'
|
|
4269
|
+
import { findUser } from '../lib/server/users'
|
|
4270
|
+
|
|
4271
|
+
export const load: PageServerLoad = async ({ cookies }) => {
|
|
4272
|
+
if (!cookies.get(TFA_COOKIE)) throw redirect(302, '/login')
|
|
4273
|
+
return {}
|
|
4274
|
+
}
|
|
4275
|
+
|
|
4276
|
+
export const actions: Actions = {
|
|
4277
|
+
default: async ({ request, cookies, url }) => {
|
|
4278
|
+
const code = String((await request.formData()).get('code') ?? '')
|
|
4279
|
+
const email = await readChallenge(cookies.get(TFA_COOKIE), code)
|
|
4280
|
+
if (!email) return fail(401, { error: 'That code is invalid or has expired.' })
|
|
4281
|
+
const user = await findUser(email)
|
|
4282
|
+
if (!user) return fail(401, { error: 'Account not found.' })
|
|
4283
|
+
cookies.delete(TFA_COOKIE, { path: '/' })
|
|
4284
|
+
cookies.set(SESSION_COOKIE, await signSession({ email: user.email, name: user.name, role: user.role }), {
|
|
4285
|
+
path: '/', httpOnly: true, sameSite: 'lax', secure: url.hostname !== 'localhost' && url.hostname !== '127.0.0.1', maxAge: SESSION_MAX_AGE,
|
|
4286
|
+
})
|
|
4287
|
+
throw redirect(302, url.searchParams.get('redirectTo') || '/')
|
|
4288
|
+
},
|
|
4289
|
+
}
|
|
4290
4290
|
`;
|
|
4291
|
-
const verifyPage = `<script lang="ts">
|
|
4292
|
-
import { enhance } from '$app/forms'
|
|
4293
|
-
let { form } = $props()
|
|
4294
|
-
</script>
|
|
4295
|
-
|
|
4296
|
-
<div class="auth">
|
|
4297
|
-
<form method="POST" use:enhance class="auth__card">
|
|
4298
|
-
<h1 class="auth__title">Enter your code</h1>
|
|
4299
|
-
<p class="auth__sub">We emailed you a 6-digit verification code.</p>
|
|
4300
|
-
{#if form?.error}<p class="auth__err" role="alert">{form.error}</p>{/if}
|
|
4301
|
-
<label class="auth__field"><span>Code</span><input name="code" inputmode="numeric" autocomplete="one-time-code" required /></label>
|
|
4302
|
-
<button class="auth__btn" type="submit">Verify</button>
|
|
4303
|
-
<p class="auth__links"><a href="/login">Back to sign in</a></p>
|
|
4304
|
-
</form>
|
|
4305
|
-
</div>
|
|
4306
|
-
|
|
4307
|
-
${authStyle}
|
|
4291
|
+
const verifyPage = `<script lang="ts">
|
|
4292
|
+
import { enhance } from '$app/forms'
|
|
4293
|
+
let { form } = $props()
|
|
4294
|
+
</script>
|
|
4295
|
+
|
|
4296
|
+
<div class="auth">
|
|
4297
|
+
<form method="POST" use:enhance class="auth__card">
|
|
4298
|
+
<h1 class="auth__title">Enter your code</h1>
|
|
4299
|
+
<p class="auth__sub">We emailed you a 6-digit verification code.</p>
|
|
4300
|
+
{#if form?.error}<p class="auth__err" role="alert">{form.error}</p>{/if}
|
|
4301
|
+
<label class="auth__field"><span>Code</span><input name="code" inputmode="numeric" autocomplete="one-time-code" required /></label>
|
|
4302
|
+
<button class="auth__btn" type="submit">Verify</button>
|
|
4303
|
+
<p class="auth__links"><a href="/login">Back to sign in</a></p>
|
|
4304
|
+
</form>
|
|
4305
|
+
</div>
|
|
4306
|
+
|
|
4307
|
+
${authStyle}
|
|
4308
4308
|
`;
|
|
4309
4309
|
// --- OAuth / OIDC (dependency-free authorization-code + PKCE) ---
|
|
4310
4310
|
const providerSet = `new Set<Provider>([${oauthProviders.map((p) => JSON.stringify(p)).join(', ')}])`;
|
|
4311
|
-
const oauthTs = `// Regenerated by SvGrid Studio. Dependency-free OAuth 2.0 / OpenID Connect sign-in
|
|
4312
|
-
// (authorization-code + PKCE). Set each provider's client id/secret in the environment;
|
|
4313
|
-
// register the redirect URI <origin>/auth/<provider>/callback with the provider.
|
|
4314
|
-
import { env } from '$env/dynamic/private'
|
|
4315
|
-
|
|
4316
|
-
export type Provider = 'github' | 'google' | 'oidc'
|
|
4317
|
-
type Endpoints = { authorize: string; token: string; userinfo: string }
|
|
4318
|
-
export type ProviderConfig = { clientId: string; clientSecret: string; scope: string; pkce: boolean; endpoints: Endpoints }
|
|
4319
|
-
|
|
4320
|
-
function b64url(bytes: ArrayBuffer): string {
|
|
4321
|
-
const b = new Uint8Array(bytes)
|
|
4322
|
-
let s = ''
|
|
4323
|
-
for (const byte of b) s += String.fromCharCode(byte)
|
|
4324
|
-
return btoa(s).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')
|
|
4325
|
-
}
|
|
4326
|
-
export function randomToken(): string { return b64url(crypto.getRandomValues(new Uint8Array(32)).buffer) }
|
|
4327
|
-
export async function pkceChallenge(verifier: string): Promise<string> {
|
|
4328
|
-
return b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)))
|
|
4329
|
-
}
|
|
4330
|
-
|
|
4331
|
-
async function endpointsFor(provider: Provider): Promise<Endpoints> {
|
|
4332
|
-
if (provider === 'github') return { authorize: 'https://github.com/login/oauth/authorize', token: 'https://github.com/login/oauth/access_token', userinfo: 'https://api.github.com/user' }
|
|
4333
|
-
if (provider === 'google') return { authorize: 'https://accounts.google.com/o/oauth2/v2/auth', token: 'https://oauth2.googleapis.com/token', userinfo: 'https://openidconnect.googleapis.com/v1/userinfo' }
|
|
4334
|
-
// Generic OIDC (Azure AD / Entra, Okta, Auth0, Keycloak): discover from OIDC_ISSUER.
|
|
4335
|
-
const issuer = (env.OIDC_ISSUER ?? '').replace(/\\/$/, '')
|
|
4336
|
-
const disc = (await (await fetch(issuer + '/.well-known/openid-configuration')).json()) as { authorization_endpoint: string; token_endpoint: string; userinfo_endpoint: string }
|
|
4337
|
-
return { authorize: disc.authorization_endpoint, token: disc.token_endpoint, userinfo: disc.userinfo_endpoint }
|
|
4338
|
-
}
|
|
4339
|
-
|
|
4340
|
-
export async function providerConfig(provider: Provider): Promise<ProviderConfig> {
|
|
4341
|
-
const prefix = provider.toUpperCase()
|
|
4342
|
-
return {
|
|
4343
|
-
clientId: env[prefix + '_CLIENT_ID'] ?? '',
|
|
4344
|
-
clientSecret: env[prefix + '_CLIENT_SECRET'] ?? '',
|
|
4345
|
-
scope: provider === 'github' ? 'read:user user:email' : 'openid email profile',
|
|
4346
|
-
pkce: provider !== 'github',
|
|
4347
|
-
endpoints: await endpointsFor(provider),
|
|
4348
|
-
}
|
|
4349
|
-
}
|
|
4350
|
-
|
|
4351
|
-
export type OAuthProfile = { email: string; name: string }
|
|
4352
|
-
export async function fetchProfile(provider: Provider, accessToken: string, userinfo: string): Promise<OAuthProfile | null> {
|
|
4353
|
-
const headers = { authorization: 'Bearer ' + accessToken, accept: 'application/json', 'user-agent': 'svgrid-app' }
|
|
4354
|
-
const data = (await (await fetch(userinfo, { headers })).json()) as Record<string, unknown>
|
|
4355
|
-
let email = typeof data.email === 'string' ? data.email : ''
|
|
4356
|
-
const name = typeof data.name === 'string' ? data.name : (typeof data.login === 'string' ? data.login : email)
|
|
4357
|
-
if (provider === 'github' && !email) {
|
|
4358
|
-
const emails = (await (await fetch('https://api.github.com/user/emails', { headers })).json()) as Array<{ email: string; primary: boolean; verified: boolean }>
|
|
4359
|
-
email = (emails.find((e) => e.primary && e.verified) ?? emails.find((e) => e.verified))?.email ?? ''
|
|
4360
|
-
}
|
|
4361
|
-
return email ? { email: email.toLowerCase(), name: name || email } : null
|
|
4362
|
-
}
|
|
4311
|
+
const oauthTs = `// Regenerated by SvGrid Studio. Dependency-free OAuth 2.0 / OpenID Connect sign-in
|
|
4312
|
+
// (authorization-code + PKCE). Set each provider's client id/secret in the environment;
|
|
4313
|
+
// register the redirect URI <origin>/auth/<provider>/callback with the provider.
|
|
4314
|
+
import { env } from '$env/dynamic/private'
|
|
4315
|
+
|
|
4316
|
+
export type Provider = 'github' | 'google' | 'oidc'
|
|
4317
|
+
type Endpoints = { authorize: string; token: string; userinfo: string }
|
|
4318
|
+
export type ProviderConfig = { clientId: string; clientSecret: string; scope: string; pkce: boolean; endpoints: Endpoints }
|
|
4319
|
+
|
|
4320
|
+
function b64url(bytes: ArrayBuffer): string {
|
|
4321
|
+
const b = new Uint8Array(bytes)
|
|
4322
|
+
let s = ''
|
|
4323
|
+
for (const byte of b) s += String.fromCharCode(byte)
|
|
4324
|
+
return btoa(s).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')
|
|
4325
|
+
}
|
|
4326
|
+
export function randomToken(): string { return b64url(crypto.getRandomValues(new Uint8Array(32)).buffer) }
|
|
4327
|
+
export async function pkceChallenge(verifier: string): Promise<string> {
|
|
4328
|
+
return b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)))
|
|
4329
|
+
}
|
|
4330
|
+
|
|
4331
|
+
async function endpointsFor(provider: Provider): Promise<Endpoints> {
|
|
4332
|
+
if (provider === 'github') return { authorize: 'https://github.com/login/oauth/authorize', token: 'https://github.com/login/oauth/access_token', userinfo: 'https://api.github.com/user' }
|
|
4333
|
+
if (provider === 'google') return { authorize: 'https://accounts.google.com/o/oauth2/v2/auth', token: 'https://oauth2.googleapis.com/token', userinfo: 'https://openidconnect.googleapis.com/v1/userinfo' }
|
|
4334
|
+
// Generic OIDC (Azure AD / Entra, Okta, Auth0, Keycloak): discover from OIDC_ISSUER.
|
|
4335
|
+
const issuer = (env.OIDC_ISSUER ?? '').replace(/\\/$/, '')
|
|
4336
|
+
const disc = (await (await fetch(issuer + '/.well-known/openid-configuration')).json()) as { authorization_endpoint: string; token_endpoint: string; userinfo_endpoint: string }
|
|
4337
|
+
return { authorize: disc.authorization_endpoint, token: disc.token_endpoint, userinfo: disc.userinfo_endpoint }
|
|
4338
|
+
}
|
|
4339
|
+
|
|
4340
|
+
export async function providerConfig(provider: Provider): Promise<ProviderConfig> {
|
|
4341
|
+
const prefix = provider.toUpperCase()
|
|
4342
|
+
return {
|
|
4343
|
+
clientId: env[prefix + '_CLIENT_ID'] ?? '',
|
|
4344
|
+
clientSecret: env[prefix + '_CLIENT_SECRET'] ?? '',
|
|
4345
|
+
scope: provider === 'github' ? 'read:user user:email' : 'openid email profile',
|
|
4346
|
+
pkce: provider !== 'github',
|
|
4347
|
+
endpoints: await endpointsFor(provider),
|
|
4348
|
+
}
|
|
4349
|
+
}
|
|
4350
|
+
|
|
4351
|
+
export type OAuthProfile = { email: string; name: string }
|
|
4352
|
+
export async function fetchProfile(provider: Provider, accessToken: string, userinfo: string): Promise<OAuthProfile | null> {
|
|
4353
|
+
const headers = { authorization: 'Bearer ' + accessToken, accept: 'application/json', 'user-agent': 'svgrid-app' }
|
|
4354
|
+
const data = (await (await fetch(userinfo, { headers })).json()) as Record<string, unknown>
|
|
4355
|
+
let email = typeof data.email === 'string' ? data.email : ''
|
|
4356
|
+
const name = typeof data.name === 'string' ? data.name : (typeof data.login === 'string' ? data.login : email)
|
|
4357
|
+
if (provider === 'github' && !email) {
|
|
4358
|
+
const emails = (await (await fetch('https://api.github.com/user/emails', { headers })).json()) as Array<{ email: string; primary: boolean; verified: boolean }>
|
|
4359
|
+
email = (emails.find((e) => e.primary && e.verified) ?? emails.find((e) => e.verified))?.email ?? ''
|
|
4360
|
+
}
|
|
4361
|
+
return email ? { email: email.toLowerCase(), name: name || email } : null
|
|
4362
|
+
}
|
|
4363
4363
|
`;
|
|
4364
|
-
const oauthLoginServerTs = `import { error, redirect } from '@sveltejs/kit'
|
|
4365
|
-
import type { RequestHandler } from './$types'
|
|
4366
|
-
import { providerConfig, randomToken, pkceChallenge, type Provider } from '../lib/server/oauth'
|
|
4367
|
-
|
|
4368
|
-
const PROVIDERS = ${providerSet}
|
|
4369
|
-
|
|
4370
|
-
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
|
4371
|
-
const provider = params.provider as Provider
|
|
4372
|
-
if (!PROVIDERS.has(provider)) throw error(404)
|
|
4373
|
-
const cfg = await providerConfig(provider)
|
|
4374
|
-
if (!cfg.clientId) throw error(500, provider + ' sign-in is not configured (set ' + provider.toUpperCase() + '_CLIENT_ID).')
|
|
4375
|
-
const secure = url.protocol === 'https:'
|
|
4376
|
-
const state = randomToken()
|
|
4377
|
-
cookies.set('oauth_state', state, { path: '/', httpOnly: true, sameSite: 'lax', maxAge: 600, secure })
|
|
4378
|
-
const q = new URLSearchParams({ client_id: cfg.clientId, redirect_uri: url.origin + '/auth/' + provider + '/callback', response_type: 'code', scope: cfg.scope, state })
|
|
4379
|
-
if (cfg.pkce) {
|
|
4380
|
-
const verifier = randomToken()
|
|
4381
|
-
cookies.set('oauth_verifier', verifier, { path: '/', httpOnly: true, sameSite: 'lax', maxAge: 600, secure })
|
|
4382
|
-
q.set('code_challenge', await pkceChallenge(verifier))
|
|
4383
|
-
q.set('code_challenge_method', 'S256')
|
|
4384
|
-
}
|
|
4385
|
-
throw redirect(302, cfg.endpoints.authorize + '?' + q.toString())
|
|
4386
|
-
}
|
|
4364
|
+
const oauthLoginServerTs = `import { error, redirect } from '@sveltejs/kit'
|
|
4365
|
+
import type { RequestHandler } from './$types'
|
|
4366
|
+
import { providerConfig, randomToken, pkceChallenge, type Provider } from '../lib/server/oauth'
|
|
4367
|
+
|
|
4368
|
+
const PROVIDERS = ${providerSet}
|
|
4369
|
+
|
|
4370
|
+
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
|
4371
|
+
const provider = params.provider as Provider
|
|
4372
|
+
if (!PROVIDERS.has(provider)) throw error(404)
|
|
4373
|
+
const cfg = await providerConfig(provider)
|
|
4374
|
+
if (!cfg.clientId) throw error(500, provider + ' sign-in is not configured (set ' + provider.toUpperCase() + '_CLIENT_ID).')
|
|
4375
|
+
const secure = url.protocol === 'https:'
|
|
4376
|
+
const state = randomToken()
|
|
4377
|
+
cookies.set('oauth_state', state, { path: '/', httpOnly: true, sameSite: 'lax', maxAge: 600, secure })
|
|
4378
|
+
const q = new URLSearchParams({ client_id: cfg.clientId, redirect_uri: url.origin + '/auth/' + provider + '/callback', response_type: 'code', scope: cfg.scope, state })
|
|
4379
|
+
if (cfg.pkce) {
|
|
4380
|
+
const verifier = randomToken()
|
|
4381
|
+
cookies.set('oauth_verifier', verifier, { path: '/', httpOnly: true, sameSite: 'lax', maxAge: 600, secure })
|
|
4382
|
+
q.set('code_challenge', await pkceChallenge(verifier))
|
|
4383
|
+
q.set('code_challenge_method', 'S256')
|
|
4384
|
+
}
|
|
4385
|
+
throw redirect(302, cfg.endpoints.authorize + '?' + q.toString())
|
|
4386
|
+
}
|
|
4387
4387
|
`;
|
|
4388
|
-
const oauthCallbackServerTs = `import { error, redirect } from '@sveltejs/kit'
|
|
4389
|
-
import type { RequestHandler } from './$types'
|
|
4390
|
-
import { providerConfig, fetchProfile, type Provider } from '../lib/server/oauth'
|
|
4391
|
-
import { findUser, createUser } from '../lib/server/users'
|
|
4392
|
-
import { SESSION_COOKIE, SESSION_MAX_AGE, signSession } from '../lib/server/auth'
|
|
4393
|
-
|
|
4394
|
-
const PROVIDERS = ${providerSet}
|
|
4395
|
-
|
|
4396
|
-
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
|
4397
|
-
const provider = params.provider as Provider
|
|
4398
|
-
if (!PROVIDERS.has(provider)) throw error(404)
|
|
4399
|
-
const code = url.searchParams.get('code')
|
|
4400
|
-
const state = url.searchParams.get('state')
|
|
4401
|
-
if (!code || !state || state !== cookies.get('oauth_state')) throw error(400, 'Invalid OAuth state.')
|
|
4402
|
-
const cfg = await providerConfig(provider)
|
|
4403
|
-
const body = new URLSearchParams({ client_id: cfg.clientId, client_secret: cfg.clientSecret, code, redirect_uri: url.origin + '/auth/' + provider + '/callback', grant_type: 'authorization_code' })
|
|
4404
|
-
const verifier = cookies.get('oauth_verifier')
|
|
4405
|
-
if (cfg.pkce && verifier) body.set('code_verifier', verifier)
|
|
4406
|
-
const tokenRes = await fetch(cfg.endpoints.token, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body })
|
|
4407
|
-
if (!tokenRes.ok) throw error(502, 'OAuth token exchange failed.')
|
|
4408
|
-
const token = (await tokenRes.json()) as { access_token?: string }
|
|
4409
|
-
if (!token.access_token) throw error(502, 'No access token returned.')
|
|
4410
|
-
const profile = await fetchProfile(provider, token.access_token, cfg.endpoints.userinfo)
|
|
4411
|
-
if (!profile) throw error(502, 'Could not read a verified email from the provider.')
|
|
4412
|
-
cookies.delete('oauth_state', { path: '/' })
|
|
4413
|
-
cookies.delete('oauth_verifier', { path: '/' })
|
|
4414
|
-
let user = await findUser(profile.email)
|
|
4415
|
-
if (!user) user = await createUser({ email: profile.email, name: profile.name, password: crypto.randomUUID() + crypto.randomUUID(), role: ${JSON.stringify(registerRole)} })
|
|
4416
|
-
if (!user) throw error(500, 'Could not create your account.')
|
|
4417
|
-
cookies.set(SESSION_COOKIE, await signSession({ email: user.email, name: user.name, role: user.role }), {
|
|
4418
|
-
path: '/', httpOnly: true, sameSite: 'lax', secure: url.protocol === 'https:', maxAge: SESSION_MAX_AGE,
|
|
4419
|
-
})
|
|
4420
|
-
throw redirect(302, '/')
|
|
4421
|
-
}
|
|
4388
|
+
const oauthCallbackServerTs = `import { error, redirect } from '@sveltejs/kit'
|
|
4389
|
+
import type { RequestHandler } from './$types'
|
|
4390
|
+
import { providerConfig, fetchProfile, type Provider } from '../lib/server/oauth'
|
|
4391
|
+
import { findUser, createUser } from '../lib/server/users'
|
|
4392
|
+
import { SESSION_COOKIE, SESSION_MAX_AGE, signSession } from '../lib/server/auth'
|
|
4393
|
+
|
|
4394
|
+
const PROVIDERS = ${providerSet}
|
|
4395
|
+
|
|
4396
|
+
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
|
4397
|
+
const provider = params.provider as Provider
|
|
4398
|
+
if (!PROVIDERS.has(provider)) throw error(404)
|
|
4399
|
+
const code = url.searchParams.get('code')
|
|
4400
|
+
const state = url.searchParams.get('state')
|
|
4401
|
+
if (!code || !state || state !== cookies.get('oauth_state')) throw error(400, 'Invalid OAuth state.')
|
|
4402
|
+
const cfg = await providerConfig(provider)
|
|
4403
|
+
const body = new URLSearchParams({ client_id: cfg.clientId, client_secret: cfg.clientSecret, code, redirect_uri: url.origin + '/auth/' + provider + '/callback', grant_type: 'authorization_code' })
|
|
4404
|
+
const verifier = cookies.get('oauth_verifier')
|
|
4405
|
+
if (cfg.pkce && verifier) body.set('code_verifier', verifier)
|
|
4406
|
+
const tokenRes = await fetch(cfg.endpoints.token, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body })
|
|
4407
|
+
if (!tokenRes.ok) throw error(502, 'OAuth token exchange failed.')
|
|
4408
|
+
const token = (await tokenRes.json()) as { access_token?: string }
|
|
4409
|
+
if (!token.access_token) throw error(502, 'No access token returned.')
|
|
4410
|
+
const profile = await fetchProfile(provider, token.access_token, cfg.endpoints.userinfo)
|
|
4411
|
+
if (!profile) throw error(502, 'Could not read a verified email from the provider.')
|
|
4412
|
+
cookies.delete('oauth_state', { path: '/' })
|
|
4413
|
+
cookies.delete('oauth_verifier', { path: '/' })
|
|
4414
|
+
let user = await findUser(profile.email)
|
|
4415
|
+
if (!user) user = await createUser({ email: profile.email, name: profile.name, password: crypto.randomUUID() + crypto.randomUUID(), role: ${JSON.stringify(registerRole)} })
|
|
4416
|
+
if (!user) throw error(500, 'Could not create your account.')
|
|
4417
|
+
cookies.set(SESSION_COOKIE, await signSession({ email: user.email, name: user.name, role: user.role }), {
|
|
4418
|
+
path: '/', httpOnly: true, sameSite: 'lax', secure: url.protocol === 'https:', maxAge: SESSION_MAX_AGE,
|
|
4419
|
+
})
|
|
4420
|
+
throw redirect(302, '/')
|
|
4421
|
+
}
|
|
4422
4422
|
`;
|
|
4423
4423
|
// NOTE: SESSION_SECRET is added to the shared .env.example by envExample() (which
|
|
4424
4424
|
// scans the generated source), so it merges with DATABASE_URL etc. - no duplicate file.
|
|
@@ -4660,79 +4660,79 @@ function dataLayerFiles(project, sqlEntities, sources, includeUsers = false, use
|
|
|
4660
4660
|
a.imports.forEach((i) => colImports.add(i));
|
|
4661
4661
|
tableBlocks.push(a.block);
|
|
4662
4662
|
}
|
|
4663
|
-
const schemaTs = `// Regenerated by SvGrid Studio. Typed database schema (Drizzle ORM) - the source of
|
|
4664
|
-
// truth for migrations: edit here, then run \`npm run db:generate\` && \`npm run db:migrate\`.
|
|
4665
|
-
import { ${[...colImports].sort().join(', ')} } from '${cfg.core}'
|
|
4666
|
-
|
|
4667
|
-
${tableBlocks.join('\n\n')}
|
|
4663
|
+
const schemaTs = `// Regenerated by SvGrid Studio. Typed database schema (Drizzle ORM) - the source of
|
|
4664
|
+
// truth for migrations: edit here, then run \`npm run db:generate\` && \`npm run db:migrate\`.
|
|
4665
|
+
import { ${[...colImports].sort().join(', ')} } from '${cfg.core}'
|
|
4666
|
+
|
|
4667
|
+
${tableBlocks.join('\n\n')}
|
|
4668
4668
|
`;
|
|
4669
|
-
const indexTs = `// Regenerated by SvGrid Studio. The Drizzle client (reads DATABASE_URL).
|
|
4670
|
-
${cfg.client}
|
|
4671
|
-
import { env } from '$env/dynamic/private'
|
|
4672
|
-
import * as schema from './schema'
|
|
4673
|
-
|
|
4674
|
-
${cfg.setup}
|
|
4675
|
-
export { schema }
|
|
4669
|
+
const indexTs = `// Regenerated by SvGrid Studio. The Drizzle client (reads DATABASE_URL).
|
|
4670
|
+
${cfg.client}
|
|
4671
|
+
import { env } from '$env/dynamic/private'
|
|
4672
|
+
import * as schema from './schema'
|
|
4673
|
+
|
|
4674
|
+
${cfg.setup}
|
|
4675
|
+
export { schema }
|
|
4676
4676
|
`;
|
|
4677
4677
|
const repoFiles = meta.map(({ e, tableVar, pkKey, pkNumber }) => {
|
|
4678
4678
|
const type = namesFor(e).type;
|
|
4679
4679
|
const idT = pkNumber ? 'number' : 'string';
|
|
4680
4680
|
// MySQL has no RETURNING, so create/update re-select the row after writing.
|
|
4681
4681
|
const crud = cfg.returning
|
|
4682
|
-
? `export const ${tableVar}Repo = {
|
|
4683
|
-
list: (): Promise<${type}Row[]> => db.select().from(${tableVar}),
|
|
4684
|
-
get: async (id: ${idT}): Promise<${type}Row | undefined> =>
|
|
4685
|
-
(await db.select().from(${tableVar}).where(eq(${tableVar}.${pkKey}, id))).at(0),
|
|
4686
|
-
create: async (values: ${type}New): Promise<${type}Row> =>
|
|
4687
|
-
(await db.insert(${tableVar}).values(values).returning()).at(0)!,
|
|
4688
|
-
update: async (id: ${idT}, values: Partial<${type}New>): Promise<${type}Row | undefined> =>
|
|
4689
|
-
(await db.update(${tableVar}).set(values).where(eq(${tableVar}.${pkKey}, id)).returning()).at(0),
|
|
4690
|
-
remove: async (id: ${idT}): Promise<void> => {
|
|
4691
|
-
await db.delete(${tableVar}).where(eq(${tableVar}.${pkKey}, id))
|
|
4692
|
-
},
|
|
4682
|
+
? `export const ${tableVar}Repo = {
|
|
4683
|
+
list: (): Promise<${type}Row[]> => db.select().from(${tableVar}),
|
|
4684
|
+
get: async (id: ${idT}): Promise<${type}Row | undefined> =>
|
|
4685
|
+
(await db.select().from(${tableVar}).where(eq(${tableVar}.${pkKey}, id))).at(0),
|
|
4686
|
+
create: async (values: ${type}New): Promise<${type}Row> =>
|
|
4687
|
+
(await db.insert(${tableVar}).values(values).returning()).at(0)!,
|
|
4688
|
+
update: async (id: ${idT}, values: Partial<${type}New>): Promise<${type}Row | undefined> =>
|
|
4689
|
+
(await db.update(${tableVar}).set(values).where(eq(${tableVar}.${pkKey}, id)).returning()).at(0),
|
|
4690
|
+
remove: async (id: ${idT}): Promise<void> => {
|
|
4691
|
+
await db.delete(${tableVar}).where(eq(${tableVar}.${pkKey}, id))
|
|
4692
|
+
},
|
|
4693
4693
|
}`
|
|
4694
|
-
: `async function getById(id: ${idT}): Promise<${type}Row | undefined> {
|
|
4695
|
-
return (await db.select().from(${tableVar}).where(eq(${tableVar}.${pkKey}, id))).at(0)
|
|
4696
|
-
}
|
|
4697
|
-
|
|
4698
|
-
export const ${tableVar}Repo = {
|
|
4699
|
-
list: (): Promise<${type}Row[]> => db.select().from(${tableVar}),
|
|
4700
|
-
get: getById,
|
|
4701
|
-
// MySQL: no RETURNING - re-select by the new/insertId after the write.
|
|
4702
|
-
create: async (values: ${type}New): Promise<${type}Row> => {
|
|
4703
|
-
const res = await db.insert(${tableVar}).values(values)
|
|
4704
|
-
const id = ((values as Record<string, unknown>).${pkKey} ?? (res as unknown as Array<{ insertId: number }>)[0]?.insertId) as ${idT}
|
|
4705
|
-
return (await getById(id))!
|
|
4706
|
-
},
|
|
4707
|
-
update: async (id: ${idT}, values: Partial<${type}New>): Promise<${type}Row | undefined> => {
|
|
4708
|
-
await db.update(${tableVar}).set(values).where(eq(${tableVar}.${pkKey}, id))
|
|
4709
|
-
return getById(id)
|
|
4710
|
-
},
|
|
4711
|
-
remove: async (id: ${idT}): Promise<void> => {
|
|
4712
|
-
await db.delete(${tableVar}).where(eq(${tableVar}.${pkKey}, id))
|
|
4713
|
-
},
|
|
4694
|
+
: `async function getById(id: ${idT}): Promise<${type}Row | undefined> {
|
|
4695
|
+
return (await db.select().from(${tableVar}).where(eq(${tableVar}.${pkKey}, id))).at(0)
|
|
4696
|
+
}
|
|
4697
|
+
|
|
4698
|
+
export const ${tableVar}Repo = {
|
|
4699
|
+
list: (): Promise<${type}Row[]> => db.select().from(${tableVar}),
|
|
4700
|
+
get: getById,
|
|
4701
|
+
// MySQL: no RETURNING - re-select by the new/insertId after the write.
|
|
4702
|
+
create: async (values: ${type}New): Promise<${type}Row> => {
|
|
4703
|
+
const res = await db.insert(${tableVar}).values(values)
|
|
4704
|
+
const id = ((values as Record<string, unknown>).${pkKey} ?? (res as unknown as Array<{ insertId: number }>)[0]?.insertId) as ${idT}
|
|
4705
|
+
return (await getById(id))!
|
|
4706
|
+
},
|
|
4707
|
+
update: async (id: ${idT}, values: Partial<${type}New>): Promise<${type}Row | undefined> => {
|
|
4708
|
+
await db.update(${tableVar}).set(values).where(eq(${tableVar}.${pkKey}, id))
|
|
4709
|
+
return getById(id)
|
|
4710
|
+
},
|
|
4711
|
+
remove: async (id: ${idT}): Promise<void> => {
|
|
4712
|
+
await db.delete(${tableVar}).where(eq(${tableVar}.${pkKey}, id))
|
|
4713
|
+
},
|
|
4714
4714
|
}`;
|
|
4715
4715
|
return {
|
|
4716
4716
|
path: `src/lib/server/db/${namesFor(e).route}.ts`,
|
|
4717
4717
|
description: `Typed repository for ${namesFor(e).label} (Drizzle).`,
|
|
4718
|
-
contents: `// Regenerated by SvGrid Studio. Typed CRUD over the ${tableVar} table - call from
|
|
4719
|
-
// server code, form actions, or your own API routes.
|
|
4720
|
-
import { eq } from 'drizzle-orm'
|
|
4721
|
-
import { db } from './index'
|
|
4722
|
-
import { ${tableVar}, type ${type}Row, type ${type}New } from './schema'
|
|
4723
|
-
|
|
4724
|
-
${crud}
|
|
4718
|
+
contents: `// Regenerated by SvGrid Studio. Typed CRUD over the ${tableVar} table - call from
|
|
4719
|
+
// server code, form actions, or your own API routes.
|
|
4720
|
+
import { eq } from 'drizzle-orm'
|
|
4721
|
+
import { db } from './index'
|
|
4722
|
+
import { ${tableVar}, type ${type}Row, type ${type}New } from './schema'
|
|
4723
|
+
|
|
4724
|
+
${crud}
|
|
4725
4725
|
`,
|
|
4726
4726
|
};
|
|
4727
4727
|
});
|
|
4728
|
-
const configTs = `import { defineConfig } from 'drizzle-kit'
|
|
4729
|
-
|
|
4730
|
-
export default defineConfig({
|
|
4731
|
-
schema: './src/lib/server/db/schema.ts',
|
|
4732
|
-
out: './drizzle',
|
|
4733
|
-
dialect: '${cfg.kit}',
|
|
4734
|
-
${cfg.creds},
|
|
4735
|
-
})
|
|
4728
|
+
const configTs = `import { defineConfig } from 'drizzle-kit'
|
|
4729
|
+
|
|
4730
|
+
export default defineConfig({
|
|
4731
|
+
schema: './src/lib/server/db/schema.ts',
|
|
4732
|
+
out: './drizzle',
|
|
4733
|
+
dialect: '${cfg.kit}',
|
|
4734
|
+
${cfg.creds},
|
|
4735
|
+
})
|
|
4736
4736
|
`;
|
|
4737
4737
|
// db/seed.ts - inserts the sample rows into EMPTY tables after `db:migrate`, so a
|
|
4738
4738
|
// freshly-migrated production database doesn't open onto blank grids. Lives outside
|
|
@@ -4764,28 +4764,28 @@ export default defineConfig({
|
|
|
4764
4764
|
});
|
|
4765
4765
|
if (!rows.length)
|
|
4766
4766
|
return null;
|
|
4767
|
-
return ` {
|
|
4768
|
-
const existing = await db.select().from(schema.${tableVar}).limit(1)
|
|
4769
|
-
if (existing.length === 0) {
|
|
4770
|
-
await db.insert(schema.${tableVar}).values(${JSON.stringify(rows)} as (typeof schema.${tableVar}.$inferInsert)[])
|
|
4771
|
-
console.log('seeded ${e.name} (${rows.length} rows)')
|
|
4772
|
-
} else console.log('${e.name} already has rows - skipped')
|
|
4767
|
+
return ` {
|
|
4768
|
+
const existing = await db.select().from(schema.${tableVar}).limit(1)
|
|
4769
|
+
if (existing.length === 0) {
|
|
4770
|
+
await db.insert(schema.${tableVar}).values(${JSON.stringify(rows)} as (typeof schema.${tableVar}.$inferInsert)[])
|
|
4771
|
+
console.log('seeded ${e.name} (${rows.length} rows)')
|
|
4772
|
+
} else console.log('${e.name} already has rows - skipped')
|
|
4773
4773
|
}`;
|
|
4774
4774
|
})
|
|
4775
4775
|
.filter((b) => b !== null);
|
|
4776
|
-
const seedTs = `// Regenerated by SvGrid Studio. studio:db-seed
|
|
4777
|
-
// Inserts sample rows into EMPTY tables - a table that already has rows is skipped,
|
|
4778
|
-
// so this is safe to run repeatedly. Run AFTER migrations: npm run db:seed
|
|
4779
|
-
${cfg.client}
|
|
4780
|
-
import * as schema from '../src/lib/server/db/schema'
|
|
4781
|
-
|
|
4782
|
-
${cfg.setup.replaceAll('env.', 'process.env.').replace('export const db', 'const db')}
|
|
4783
|
-
|
|
4784
|
-
async function main() {
|
|
4785
|
-
${seedBlocks.join('\n')}
|
|
4786
|
-
}
|
|
4787
|
-
|
|
4788
|
-
main().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1) })
|
|
4776
|
+
const seedTs = `// Regenerated by SvGrid Studio. studio:db-seed
|
|
4777
|
+
// Inserts sample rows into EMPTY tables - a table that already has rows is skipped,
|
|
4778
|
+
// so this is safe to run repeatedly. Run AFTER migrations: npm run db:seed
|
|
4779
|
+
${cfg.client}
|
|
4780
|
+
import * as schema from '../src/lib/server/db/schema'
|
|
4781
|
+
|
|
4782
|
+
${cfg.setup.replaceAll('env.', 'process.env.').replace('export const db', 'const db')}
|
|
4783
|
+
|
|
4784
|
+
async function main() {
|
|
4785
|
+
${seedBlocks.join('\n')}
|
|
4786
|
+
}
|
|
4787
|
+
|
|
4788
|
+
main().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1) })
|
|
4789
4789
|
`;
|
|
4790
4790
|
return [
|
|
4791
4791
|
{ path: 'src/lib/server/db/schema.ts', description: 'Drizzle schema (migration source of truth).', contents: schemaTs },
|
|
@@ -4832,106 +4832,106 @@ const SCAFFOLD_STATIC = [
|
|
|
4832
4832
|
{ path: '.npmrc', description: 'npm config.', contents: `engine-strict=false\n` },
|
|
4833
4833
|
{ path: '.gitignore', description: 'git ignore.', contents: `node_modules\n.svelte-kit\n/build\n.env\n.env.*\n!.env.example\n.DS_Store\n` },
|
|
4834
4834
|
];
|
|
4835
|
-
const APP_CSS = `:root { --sg-accent: #4f46e5; color-scheme: light dark; }
|
|
4836
|
-
* { box-sizing: border-box; }
|
|
4837
|
-
html, body { margin: 0; height: 100%; }
|
|
4838
|
-
body { font-family: var(--sg-font, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif); color: var(--sg-fg, #0f172a); background: var(--sg-bg, #fff); }
|
|
4839
|
-
.st__title { margin: 0; font-size: 22px; font-weight: 720; letter-spacing: -0.015em; }
|
|
4840
|
-
.st__sub { margin: 0; font-size: 14px; line-height: 1.6; color: var(--sg-muted, #64748b); max-width: 74ch; }
|
|
4841
|
-
.st__sub code { background: var(--sg-header-bg, #f1f5f9); padding: 1px 6px; border-radius: 5px; font-size: 0.9em; }
|
|
4842
|
-
.st__toolbar { display: flex; align-items: center; gap: 10px; }
|
|
4843
|
-
.st-hint { font-size: 12.5px; color: var(--sg-muted, #94a3b8); }
|
|
4844
|
-
.st-error { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 0 0 16px; padding: 11px 14px; border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 40%, var(--sg-border, #e6e8ec)); border-radius: 10px; background: color-mix(in srgb, var(--sg-danger, #dc2626) 8%, var(--sg-bg, #fff)); color: var(--sg-danger, #b3261e); font-size: 13.5px; }
|
|
4845
|
-
.st-btn { display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px; font: inherit; font-size: 13.5px; font-weight: 560; line-height: 1; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 10px; background: var(--sg-bg, #fff); color: var(--sg-fg, inherit); cursor: pointer; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); }
|
|
4846
|
-
.st-btn:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 5%, var(--sg-bg, #fff)); }
|
|
4847
|
-
.st-btn:disabled { opacity: 0.5; cursor: default; box-shadow: none; }
|
|
4848
|
-
.st-btn--primary { border-color: transparent; color: #fff; background: linear-gradient(180deg, color-mix(in srgb, var(--sg-accent) 88%, #fff), var(--sg-accent)); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.14), 0 8px 18px -9px color-mix(in srgb, var(--sg-accent) 65%, transparent); }
|
|
4849
|
-
.st-btn--primary:hover { filter: brightness(1.06); }
|
|
4850
|
-
.home { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; margin-top: 6px; }
|
|
4851
|
-
.home__card { display: flex; flex-direction: column; gap: 6px; padding: 18px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 14px; text-decoration: none; color: inherit; background: var(--sg-bg, #fff); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); }
|
|
4852
|
-
.home__card:hover { border-color: color-mix(in srgb, var(--sg-accent) 45%, var(--sg-border, #e6e8ec)); }
|
|
4853
|
-
.home__card strong { font-size: 15px; }
|
|
4854
|
-
.home__card span { font-size: 13px; color: var(--sg-muted, #64748b); line-height: 1.5; }
|
|
4855
|
-
.st-filter { display: flex; flex-direction: column; gap: 10px; padding: 14px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); align-self: start; }
|
|
4856
|
-
.st-filter__title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--sg-muted, #64748b); }
|
|
4857
|
-
.st-filter__row { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); }
|
|
4858
|
-
.st-filter__row select, .st-filter__row input { padding: 7px 9px; font: inherit; font-size: 13px; font-weight: 400; color: var(--sg-fg, inherit); background: var(--sg-input-bg, var(--sg-bg, #fff)); border: 1px solid var(--sg-input-border, var(--sg-border, #e6e8ec)); border-radius: 8px; }
|
|
4859
|
-
.st-record-card { border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); padding: 14px; align-self: start; }
|
|
4860
|
-
.st-record { margin: 0; display: flex; flex-direction: column; gap: 8px; }
|
|
4861
|
-
.st-record__row { display: grid; grid-template-columns: 40% 1fr; gap: 10px; align-items: baseline; border-bottom: 1px solid var(--sg-border, #f1f5f9); padding-bottom: 6px; }
|
|
4862
|
-
.st-record__row dt { margin: 0; font-size: 12px; font-weight: 600; color: var(--sg-muted, #64748b); }
|
|
4863
|
-
.st-record__row dd { margin: 0; font-size: 13.5px; color: var(--sg-fg, inherit); overflow-wrap: anywhere; }
|
|
4864
|
-
.st-screen { display: grid; grid-template-columns: repeat(12, 1fr); gap: 16px; align-items: start; }
|
|
4865
|
-
/* Mobile: blocks stack full-width (a span-N block clamps to the single column). */
|
|
4866
|
-
@media (max-width: 720px) { .st-screen { grid-template-columns: 1fr; gap: 12px; } }
|
|
4867
|
-
/* Stack layout: every block full-width in a single flowing column (mobile-first). */
|
|
4868
|
-
.st-stack { display: flex; flex-direction: column; gap: 16px; align-items: stretch; }
|
|
4869
|
-
.st-stack > * { min-width: 0; width: 100%; }
|
|
4870
|
-
@media (max-width: 720px) { .st-stack { gap: 12px; } }
|
|
4871
|
-
/* Free-form canvas: blocks placed on a 12-column x fixed-row grid by cell coords. */
|
|
4872
|
-
.st-canvas { display: grid; grid-template-columns: repeat(12, 1fr); grid-auto-rows: ${CANVAS_ROW_PX}px; gap: ${CANVAS_GAP_PX}px; align-items: stretch; }
|
|
4873
|
-
.st-canvas__cell { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
|
4874
|
-
.st-canvas__cell > * { flex: 1; min-height: 0; }
|
|
4875
|
-
/* Narrow: an absolute canvas can't reflow, so fall back to a single stacked column. */
|
|
4876
|
-
@media (max-width: 720px) {
|
|
4877
|
-
.st-canvas { display: flex; flex-direction: column; gap: 12px; }
|
|
4878
|
-
.st-canvas__cell { grid-column: auto !important; grid-row: auto !important; min-height: ${CANVAS_ROW_PX * 4}px; }
|
|
4879
|
-
}
|
|
4880
|
-
/* Docking workspace: the SvDockManager fills a sized region (it owns its own scrolling). */
|
|
4881
|
-
.st-dock { height: min(74vh, 900px); min-height: 420px; }
|
|
4882
|
-
@media (max-width: 640px) { .st__title { font-size: 19px; } }
|
|
4883
|
-
.st-rowactions { display: inline-flex; gap: 6px; }
|
|
4884
|
-
.st-rowaction { padding: 3px 9px; font: inherit; font-size: 12px; font-weight: 550; line-height: 1.4; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 7px; background: var(--sg-bg, #fff); color: var(--sg-fg, inherit); cursor: pointer; }
|
|
4885
|
-
.st-rowaction:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 5%, var(--sg-bg, #fff)); }
|
|
4886
|
-
.st-rowaction--danger { color: #dc2626; border-color: color-mix(in srgb, #dc2626 40%, var(--sg-border, #e6e8ec)); }
|
|
4887
|
-
.st-rowaction--danger:hover { background: color-mix(in srgb, #dc2626 8%, var(--sg-bg, #fff)); }
|
|
4888
|
-
.st-cell-link { color: var(--sg-accent, #4f46e5); text-decoration: none; }
|
|
4889
|
-
.st-cell-link:hover { text-decoration: underline; }
|
|
4890
|
-
.st-cell-progress { display: flex; align-items: center; min-width: 80px; width: 100%; }
|
|
4891
|
-
.st-grid-toolbar { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; margin-bottom: 8px; }
|
|
4892
|
-
.st-treecell { display: inline-flex; align-items: center; gap: 4px; }
|
|
4893
|
-
.st-tree-toggle { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; padding: 0; border: none; background: none; color: var(--sg-muted, #94a3b8); font-size: 11px; cursor: pointer; border-radius: 4px; }
|
|
4894
|
-
.st-tree-toggle:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 8%, transparent); color: var(--sg-fg, inherit); }
|
|
4895
|
-
.st-tree-spacer { display: inline-block; width: 18px; }
|
|
4835
|
+
const APP_CSS = `:root { --sg-accent: #4f46e5; color-scheme: light dark; }
|
|
4836
|
+
* { box-sizing: border-box; }
|
|
4837
|
+
html, body { margin: 0; height: 100%; }
|
|
4838
|
+
body { font-family: var(--sg-font, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif); color: var(--sg-fg, #0f172a); background: var(--sg-bg, #fff); }
|
|
4839
|
+
.st__title { margin: 0; font-size: 22px; font-weight: 720; letter-spacing: -0.015em; }
|
|
4840
|
+
.st__sub { margin: 0; font-size: 14px; line-height: 1.6; color: var(--sg-muted, #64748b); max-width: 74ch; }
|
|
4841
|
+
.st__sub code { background: var(--sg-header-bg, #f1f5f9); padding: 1px 6px; border-radius: 5px; font-size: 0.9em; }
|
|
4842
|
+
.st__toolbar { display: flex; align-items: center; gap: 10px; }
|
|
4843
|
+
.st-hint { font-size: 12.5px; color: var(--sg-muted, #94a3b8); }
|
|
4844
|
+
.st-error { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 0 0 16px; padding: 11px 14px; border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 40%, var(--sg-border, #e6e8ec)); border-radius: 10px; background: color-mix(in srgb, var(--sg-danger, #dc2626) 8%, var(--sg-bg, #fff)); color: var(--sg-danger, #b3261e); font-size: 13.5px; }
|
|
4845
|
+
.st-btn { display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px; font: inherit; font-size: 13.5px; font-weight: 560; line-height: 1; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 10px; background: var(--sg-bg, #fff); color: var(--sg-fg, inherit); cursor: pointer; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); }
|
|
4846
|
+
.st-btn:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 5%, var(--sg-bg, #fff)); }
|
|
4847
|
+
.st-btn:disabled { opacity: 0.5; cursor: default; box-shadow: none; }
|
|
4848
|
+
.st-btn--primary { border-color: transparent; color: #fff; background: linear-gradient(180deg, color-mix(in srgb, var(--sg-accent) 88%, #fff), var(--sg-accent)); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.14), 0 8px 18px -9px color-mix(in srgb, var(--sg-accent) 65%, transparent); }
|
|
4849
|
+
.st-btn--primary:hover { filter: brightness(1.06); }
|
|
4850
|
+
.home { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; margin-top: 6px; }
|
|
4851
|
+
.home__card { display: flex; flex-direction: column; gap: 6px; padding: 18px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 14px; text-decoration: none; color: inherit; background: var(--sg-bg, #fff); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); }
|
|
4852
|
+
.home__card:hover { border-color: color-mix(in srgb, var(--sg-accent) 45%, var(--sg-border, #e6e8ec)); }
|
|
4853
|
+
.home__card strong { font-size: 15px; }
|
|
4854
|
+
.home__card span { font-size: 13px; color: var(--sg-muted, #64748b); line-height: 1.5; }
|
|
4855
|
+
.st-filter { display: flex; flex-direction: column; gap: 10px; padding: 14px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); align-self: start; }
|
|
4856
|
+
.st-filter__title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--sg-muted, #64748b); }
|
|
4857
|
+
.st-filter__row { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); }
|
|
4858
|
+
.st-filter__row select, .st-filter__row input { padding: 7px 9px; font: inherit; font-size: 13px; font-weight: 400; color: var(--sg-fg, inherit); background: var(--sg-input-bg, var(--sg-bg, #fff)); border: 1px solid var(--sg-input-border, var(--sg-border, #e6e8ec)); border-radius: 8px; }
|
|
4859
|
+
.st-record-card { border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); padding: 14px; align-self: start; }
|
|
4860
|
+
.st-record { margin: 0; display: flex; flex-direction: column; gap: 8px; }
|
|
4861
|
+
.st-record__row { display: grid; grid-template-columns: 40% 1fr; gap: 10px; align-items: baseline; border-bottom: 1px solid var(--sg-border, #f1f5f9); padding-bottom: 6px; }
|
|
4862
|
+
.st-record__row dt { margin: 0; font-size: 12px; font-weight: 600; color: var(--sg-muted, #64748b); }
|
|
4863
|
+
.st-record__row dd { margin: 0; font-size: 13.5px; color: var(--sg-fg, inherit); overflow-wrap: anywhere; }
|
|
4864
|
+
.st-screen { display: grid; grid-template-columns: repeat(12, 1fr); gap: 16px; align-items: start; }
|
|
4865
|
+
/* Mobile: blocks stack full-width (a span-N block clamps to the single column). */
|
|
4866
|
+
@media (max-width: 720px) { .st-screen { grid-template-columns: 1fr; gap: 12px; } }
|
|
4867
|
+
/* Stack layout: every block full-width in a single flowing column (mobile-first). */
|
|
4868
|
+
.st-stack { display: flex; flex-direction: column; gap: 16px; align-items: stretch; }
|
|
4869
|
+
.st-stack > * { min-width: 0; width: 100%; }
|
|
4870
|
+
@media (max-width: 720px) { .st-stack { gap: 12px; } }
|
|
4871
|
+
/* Free-form canvas: blocks placed on a 12-column x fixed-row grid by cell coords. */
|
|
4872
|
+
.st-canvas { display: grid; grid-template-columns: repeat(12, 1fr); grid-auto-rows: ${CANVAS_ROW_PX}px; gap: ${CANVAS_GAP_PX}px; align-items: stretch; }
|
|
4873
|
+
.st-canvas__cell { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
|
4874
|
+
.st-canvas__cell > * { flex: 1; min-height: 0; }
|
|
4875
|
+
/* Narrow: an absolute canvas can't reflow, so fall back to a single stacked column. */
|
|
4876
|
+
@media (max-width: 720px) {
|
|
4877
|
+
.st-canvas { display: flex; flex-direction: column; gap: 12px; }
|
|
4878
|
+
.st-canvas__cell { grid-column: auto !important; grid-row: auto !important; min-height: ${CANVAS_ROW_PX * 4}px; }
|
|
4879
|
+
}
|
|
4880
|
+
/* Docking workspace: the SvDockManager fills a sized region (it owns its own scrolling). */
|
|
4881
|
+
.st-dock { height: min(74vh, 900px); min-height: 420px; }
|
|
4882
|
+
@media (max-width: 640px) { .st__title { font-size: 19px; } }
|
|
4883
|
+
.st-rowactions { display: inline-flex; gap: 6px; }
|
|
4884
|
+
.st-rowaction { padding: 3px 9px; font: inherit; font-size: 12px; font-weight: 550; line-height: 1.4; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 7px; background: var(--sg-bg, #fff); color: var(--sg-fg, inherit); cursor: pointer; }
|
|
4885
|
+
.st-rowaction:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 5%, var(--sg-bg, #fff)); }
|
|
4886
|
+
.st-rowaction--danger { color: #dc2626; border-color: color-mix(in srgb, #dc2626 40%, var(--sg-border, #e6e8ec)); }
|
|
4887
|
+
.st-rowaction--danger:hover { background: color-mix(in srgb, #dc2626 8%, var(--sg-bg, #fff)); }
|
|
4888
|
+
.st-cell-link { color: var(--sg-accent, #4f46e5); text-decoration: none; }
|
|
4889
|
+
.st-cell-link:hover { text-decoration: underline; }
|
|
4890
|
+
.st-cell-progress { display: flex; align-items: center; min-width: 80px; width: 100%; }
|
|
4891
|
+
.st-grid-toolbar { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; margin-bottom: 8px; }
|
|
4892
|
+
.st-treecell { display: inline-flex; align-items: center; gap: 4px; }
|
|
4893
|
+
.st-tree-toggle { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; padding: 0; border: none; background: none; color: var(--sg-muted, #94a3b8); font-size: 11px; cursor: pointer; border-radius: 4px; }
|
|
4894
|
+
.st-tree-toggle:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 8%, transparent); color: var(--sg-fg, inherit); }
|
|
4895
|
+
.st-tree-spacer { display: inline-block; width: 18px; }
|
|
4896
4896
|
`;
|
|
4897
4897
|
// NOTE: the generated app ships without a package-lock.json, so the workflows use
|
|
4898
4898
|
// `npm install` (not `npm ci`) and omit setup-node's lockfile-dependent `cache: npm`
|
|
4899
4899
|
// - both hard-fail when no lockfile is present. Commit a lockfile later to speed CI up.
|
|
4900
4900
|
/** Universal CI: install + build + smoke tests on every push / PR. */
|
|
4901
|
-
const CI_WORKFLOW = `name: CI
|
|
4902
|
-
on:
|
|
4903
|
-
push:
|
|
4904
|
-
branches: [main]
|
|
4905
|
-
pull_request:
|
|
4906
|
-
branches: [main]
|
|
4907
|
-
jobs:
|
|
4908
|
-
build:
|
|
4909
|
-
runs-on: ubuntu-latest
|
|
4910
|
-
steps:
|
|
4911
|
-
- uses: actions/checkout@v4
|
|
4912
|
-
- uses: actions/setup-node@v4
|
|
4913
|
-
with:
|
|
4914
|
-
node-version: 20
|
|
4915
|
-
- run: npm install
|
|
4916
|
-
- run: npm run build
|
|
4917
|
-
- run: npm test
|
|
4901
|
+
const CI_WORKFLOW = `name: CI
|
|
4902
|
+
on:
|
|
4903
|
+
push:
|
|
4904
|
+
branches: [main]
|
|
4905
|
+
pull_request:
|
|
4906
|
+
branches: [main]
|
|
4907
|
+
jobs:
|
|
4908
|
+
build:
|
|
4909
|
+
runs-on: ubuntu-latest
|
|
4910
|
+
steps:
|
|
4911
|
+
- uses: actions/checkout@v4
|
|
4912
|
+
- uses: actions/setup-node@v4
|
|
4913
|
+
with:
|
|
4914
|
+
node-version: 20
|
|
4915
|
+
- run: npm install
|
|
4916
|
+
- run: npm run build
|
|
4917
|
+
- run: npm test
|
|
4918
4918
|
`;
|
|
4919
4919
|
/** Wrap deploy steps in the standard checkout + node + install prelude. */
|
|
4920
4920
|
function deployWorkflow(steps) {
|
|
4921
|
-
return `name: Deploy
|
|
4922
|
-
on:
|
|
4923
|
-
push:
|
|
4924
|
-
branches: [main]
|
|
4925
|
-
workflow_dispatch:
|
|
4926
|
-
jobs:
|
|
4927
|
-
deploy:
|
|
4928
|
-
runs-on: ubuntu-latest
|
|
4929
|
-
steps:
|
|
4930
|
-
- uses: actions/checkout@v4
|
|
4931
|
-
- uses: actions/setup-node@v4
|
|
4932
|
-
with:
|
|
4933
|
-
node-version: 20
|
|
4934
|
-
- run: npm install
|
|
4921
|
+
return `name: Deploy
|
|
4922
|
+
on:
|
|
4923
|
+
push:
|
|
4924
|
+
branches: [main]
|
|
4925
|
+
workflow_dispatch:
|
|
4926
|
+
jobs:
|
|
4927
|
+
deploy:
|
|
4928
|
+
runs-on: ubuntu-latest
|
|
4929
|
+
steps:
|
|
4930
|
+
- uses: actions/checkout@v4
|
|
4931
|
+
- uses: actions/setup-node@v4
|
|
4932
|
+
with:
|
|
4933
|
+
node-version: 20
|
|
4934
|
+
- run: npm install
|
|
4935
4935
|
${steps}`;
|
|
4936
4936
|
}
|
|
4937
4937
|
function deployPlan(project) {
|
|
@@ -4948,17 +4948,17 @@ function deployPlan(project) {
|
|
|
4948
4948
|
dashboard: 'https://vercel.com/new',
|
|
4949
4949
|
label: 'Vercel',
|
|
4950
4950
|
secrets: ['VERCEL_TOKEN', 'VERCEL_ORG_ID', 'VERCEL_PROJECT_ID'],
|
|
4951
|
-
deployWorkflow: deployWorkflow(` - name: Deploy to Vercel
|
|
4952
|
-
if: \${{ secrets.VERCEL_TOKEN != '' }}
|
|
4953
|
-
env:
|
|
4954
|
-
VERCEL_TOKEN: \${{ secrets.VERCEL_TOKEN }}
|
|
4955
|
-
VERCEL_ORG_ID: \${{ secrets.VERCEL_ORG_ID }}
|
|
4956
|
-
VERCEL_PROJECT_ID: \${{ secrets.VERCEL_PROJECT_ID }}
|
|
4957
|
-
run: |
|
|
4958
|
-
npm i -g vercel
|
|
4959
|
-
vercel pull --yes --environment=production --token="$VERCEL_TOKEN"
|
|
4960
|
-
vercel build --prod --token="$VERCEL_TOKEN"
|
|
4961
|
-
vercel deploy --prebuilt --prod --token="$VERCEL_TOKEN"
|
|
4951
|
+
deployWorkflow: deployWorkflow(` - name: Deploy to Vercel
|
|
4952
|
+
if: \${{ secrets.VERCEL_TOKEN != '' }}
|
|
4953
|
+
env:
|
|
4954
|
+
VERCEL_TOKEN: \${{ secrets.VERCEL_TOKEN }}
|
|
4955
|
+
VERCEL_ORG_ID: \${{ secrets.VERCEL_ORG_ID }}
|
|
4956
|
+
VERCEL_PROJECT_ID: \${{ secrets.VERCEL_PROJECT_ID }}
|
|
4957
|
+
run: |
|
|
4958
|
+
npm i -g vercel
|
|
4959
|
+
vercel pull --yes --environment=production --token="$VERCEL_TOKEN"
|
|
4960
|
+
vercel build --prod --token="$VERCEL_TOKEN"
|
|
4961
|
+
vercel deploy --prebuilt --prod --token="$VERCEL_TOKEN"
|
|
4962
4962
|
`),
|
|
4963
4963
|
};
|
|
4964
4964
|
case 'netlify':
|
|
@@ -4971,12 +4971,12 @@ function deployPlan(project) {
|
|
|
4971
4971
|
dashboard: 'https://app.netlify.com/start',
|
|
4972
4972
|
label: 'Netlify',
|
|
4973
4973
|
secrets: ['NETLIFY_AUTH_TOKEN', 'NETLIFY_SITE_ID'],
|
|
4974
|
-
deployWorkflow: deployWorkflow(` - name: Deploy to Netlify
|
|
4975
|
-
if: \${{ secrets.NETLIFY_AUTH_TOKEN != '' }}
|
|
4976
|
-
env:
|
|
4977
|
-
NETLIFY_AUTH_TOKEN: \${{ secrets.NETLIFY_AUTH_TOKEN }}
|
|
4978
|
-
NETLIFY_SITE_ID: \${{ secrets.NETLIFY_SITE_ID }}
|
|
4979
|
-
run: npx netlify deploy --build --prod
|
|
4974
|
+
deployWorkflow: deployWorkflow(` - name: Deploy to Netlify
|
|
4975
|
+
if: \${{ secrets.NETLIFY_AUTH_TOKEN != '' }}
|
|
4976
|
+
env:
|
|
4977
|
+
NETLIFY_AUTH_TOKEN: \${{ secrets.NETLIFY_AUTH_TOKEN }}
|
|
4978
|
+
NETLIFY_SITE_ID: \${{ secrets.NETLIFY_SITE_ID }}
|
|
4979
|
+
run: npx netlify deploy --build --prod
|
|
4980
4980
|
`),
|
|
4981
4981
|
};
|
|
4982
4982
|
case 'cloudflare':
|
|
@@ -4989,13 +4989,13 @@ function deployPlan(project) {
|
|
|
4989
4989
|
dashboard: 'https://dash.cloudflare.com/?to=/:account/pages/new',
|
|
4990
4990
|
label: 'Cloudflare Pages',
|
|
4991
4991
|
secrets: ['CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_ACCOUNT_ID'],
|
|
4992
|
-
deployWorkflow: deployWorkflow(` - run: npm run build
|
|
4993
|
-
- name: Deploy to Cloudflare Pages
|
|
4994
|
-
if: \${{ secrets.CLOUDFLARE_API_TOKEN != '' }}
|
|
4995
|
-
env:
|
|
4996
|
-
CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
4997
|
-
CLOUDFLARE_ACCOUNT_ID: \${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
4998
|
-
run: npx wrangler pages deploy .svelte-kit/cloudflare --project-name=${slug}
|
|
4992
|
+
deployWorkflow: deployWorkflow(` - run: npm run build
|
|
4993
|
+
- name: Deploy to Cloudflare Pages
|
|
4994
|
+
if: \${{ secrets.CLOUDFLARE_API_TOKEN != '' }}
|
|
4995
|
+
env:
|
|
4996
|
+
CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
4997
|
+
CLOUDFLARE_ACCOUNT_ID: \${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
4998
|
+
run: npx wrangler pages deploy .svelte-kit/cloudflare --project-name=${slug}
|
|
4999
4999
|
`),
|
|
5000
5000
|
};
|
|
5001
5001
|
case 'node':
|
|
@@ -5273,40 +5273,40 @@ function smokeTestFile(project) {
|
|
|
5273
5273
|
});
|
|
5274
5274
|
const blocks = project.entities.map((e) => {
|
|
5275
5275
|
const n = namesFor(e);
|
|
5276
|
-
return `describe(${JSON.stringify(n.label)}, () => {
|
|
5277
|
-
it('exposes grid columns and form fields', () => {
|
|
5278
|
-
expect(schemaToColumns(${n.schemaVar}).length).toBeGreaterThan(0)
|
|
5279
|
-
expect(schemaToFormFields(${n.schemaVar}).length).toBeGreaterThan(0)
|
|
5280
|
-
})
|
|
5281
|
-
|
|
5282
|
-
it('round-trips create -> read -> delete through an in-memory source', async () => {
|
|
5283
|
-
const idField = ${n.schemaVar}.idField ?? ${n.schemaVar}.fields.find((f) => f.primaryKey)?.field ?? 'id'
|
|
5284
|
-
const source = createInMemoryDataSource<${n.type}>([], ${n.schemaVar})
|
|
5285
|
-
await source.createRow({ [idField]: 'smoke-1' } as unknown as Partial<${n.type}>)
|
|
5286
|
-
expect((await source.getRows({ startRow: 0, endRow: 100, pageIndex: 0, pageSize: 100, sortModel: [], filterModel: {} })).rowCount).toBe(1)
|
|
5287
|
-
await source.deleteRow('smoke-1')
|
|
5288
|
-
expect((await source.getRows({ startRow: 0, endRow: 100, pageIndex: 0, pageSize: 100, sortModel: [], filterModel: {} })).rowCount).toBe(0)
|
|
5289
|
-
})
|
|
5276
|
+
return `describe(${JSON.stringify(n.label)}, () => {
|
|
5277
|
+
it('exposes grid columns and form fields', () => {
|
|
5278
|
+
expect(schemaToColumns(${n.schemaVar}).length).toBeGreaterThan(0)
|
|
5279
|
+
expect(schemaToFormFields(${n.schemaVar}).length).toBeGreaterThan(0)
|
|
5280
|
+
})
|
|
5281
|
+
|
|
5282
|
+
it('round-trips create -> read -> delete through an in-memory source', async () => {
|
|
5283
|
+
const idField = ${n.schemaVar}.idField ?? ${n.schemaVar}.fields.find((f) => f.primaryKey)?.field ?? 'id'
|
|
5284
|
+
const source = createInMemoryDataSource<${n.type}>([], ${n.schemaVar})
|
|
5285
|
+
await source.createRow({ [idField]: 'smoke-1' } as unknown as Partial<${n.type}>)
|
|
5286
|
+
expect((await source.getRows({ startRow: 0, endRow: 100, pageIndex: 0, pageSize: 100, sortModel: [], filterModel: {} })).rowCount).toBe(1)
|
|
5287
|
+
await source.deleteRow('smoke-1')
|
|
5288
|
+
expect((await source.getRows({ startRow: 0, endRow: 100, pageIndex: 0, pageSize: 100, sortModel: [], filterModel: {} })).rowCount).toBe(0)
|
|
5289
|
+
})
|
|
5290
5290
|
})`;
|
|
5291
5291
|
});
|
|
5292
|
-
return `// Smoke tests generated by SvGrid Studio. Run with \`npm test\`.
|
|
5293
|
-
// They prove every entity's schema still renders (grid columns + form fields)
|
|
5294
|
-
// and round-trips through the data-source layer, so a schema edit that would
|
|
5295
|
-
// break a screen fails here first. Regenerating the app refreshes this file.
|
|
5296
|
-
import { describe, it, expect } from 'vitest'
|
|
5297
|
-
import { schemaToColumns, schemaToFormFields, createInMemoryDataSource } from '@svgrid/enterprise'
|
|
5298
|
-
import { ${imports.join(', ')} } from './schemas'
|
|
5299
|
-
|
|
5300
|
-
${blocks.join('\n\n')}
|
|
5292
|
+
return `// Smoke tests generated by SvGrid Studio. Run with \`npm test\`.
|
|
5293
|
+
// They prove every entity's schema still renders (grid columns + form fields)
|
|
5294
|
+
// and round-trips through the data-source layer, so a schema edit that would
|
|
5295
|
+
// break a screen fails here first. Regenerating the app refreshes this file.
|
|
5296
|
+
import { describe, it, expect } from 'vitest'
|
|
5297
|
+
import { schemaToColumns, schemaToFormFields, createInMemoryDataSource } from '@svgrid/enterprise'
|
|
5298
|
+
import { ${imports.join(', ')} } from './schemas'
|
|
5299
|
+
|
|
5300
|
+
${blocks.join('\n\n')}
|
|
5301
5301
|
`;
|
|
5302
5302
|
}
|
|
5303
|
-
const VITEST_CONFIG = `import { defineConfig } from 'vitest/config'
|
|
5304
|
-
|
|
5305
|
-
// Node-only test runner for the generated smoke tests (no Svelte/DOM needed).
|
|
5306
|
-
// Kept separate from vite.config so the SvelteKit plugin doesn't load here.
|
|
5307
|
-
export default defineConfig({
|
|
5308
|
-
test: { environment: 'node', include: ['src/**/*.test.ts'] },
|
|
5309
|
-
})
|
|
5303
|
+
const VITEST_CONFIG = `import { defineConfig } from 'vitest/config'
|
|
5304
|
+
|
|
5305
|
+
// Node-only test runner for the generated smoke tests (no Svelte/DOM needed).
|
|
5306
|
+
// Kept separate from vite.config so the SvelteKit plugin doesn't load here.
|
|
5307
|
+
export default defineConfig({
|
|
5308
|
+
test: { environment: 'node', include: ['src/**/*.test.ts'] },
|
|
5309
|
+
})
|
|
5310
5310
|
`;
|
|
5311
5311
|
/**
|
|
5312
5312
|
* Emit the COMPLETE runnable SvelteKit + Vite app: the generated screens/data
|
|
@@ -5336,38 +5336,38 @@ function cronScheduleFiles(project, plan) {
|
|
|
5336
5336
|
contents: JSON.stringify({ crons }, null, 2) + '\n',
|
|
5337
5337
|
}];
|
|
5338
5338
|
}
|
|
5339
|
-
const steps = jobs.map((j) => ` - name: ${j.name} (${j.cron})
|
|
5340
|
-
if: \${{ github.event_name == 'workflow_dispatch' || github.event.schedule == '${j.cron}' }}
|
|
5341
|
-
run: |
|
|
5342
|
-
curl -fsS -X POST "$CRON_URL?job=${encodeURIComponent(j.id)}" \\
|
|
5343
|
-
-H "authorization: Bearer $CRON_SECRET"
|
|
5339
|
+
const steps = jobs.map((j) => ` - name: ${j.name} (${j.cron})
|
|
5340
|
+
if: \${{ github.event_name == 'workflow_dispatch' || github.event.schedule == '${j.cron}' }}
|
|
5341
|
+
run: |
|
|
5342
|
+
curl -fsS -X POST "$CRON_URL?job=${encodeURIComponent(j.id)}" \\
|
|
5343
|
+
-H "authorization: Bearer $CRON_SECRET"
|
|
5344
5344
|
`).join('');
|
|
5345
5345
|
const schedules = [...new Set(jobs.map((j) => j.cron))].map((c) => ` - cron: '${c}'`).join('\n');
|
|
5346
5346
|
return [{
|
|
5347
5347
|
path: '.github/workflows/cron.yml',
|
|
5348
5348
|
description: 'Scheduled job runner: calls /api/cron on the deployed app.',
|
|
5349
|
-
contents: `# Scheduled jobs for the generated app. GitHub runs this on the schedules
|
|
5350
|
-
# below and it calls the app's /api/cron endpoint.
|
|
5351
|
-
#
|
|
5352
|
-
# Set two repository secrets before it does anything:
|
|
5353
|
-
# CRON_URL https://<your-app>/api/cron
|
|
5354
|
-
# CRON_SECRET the same value as the app's CRON_SECRET env var
|
|
5355
|
-
# Until then the job short-circuits, so CI stays green.
|
|
5356
|
-
name: Scheduled jobs
|
|
5357
|
-
|
|
5358
|
-
on:
|
|
5359
|
-
schedule:
|
|
5360
|
-
${schedules}
|
|
5361
|
-
workflow_dispatch:
|
|
5362
|
-
|
|
5363
|
-
jobs:
|
|
5364
|
-
cron:
|
|
5365
|
-
runs-on: ubuntu-latest
|
|
5366
|
-
if: \${{ secrets.CRON_URL != '' && secrets.CRON_SECRET != '' }}
|
|
5367
|
-
env:
|
|
5368
|
-
CRON_URL: \${{ secrets.CRON_URL }}
|
|
5369
|
-
CRON_SECRET: \${{ secrets.CRON_SECRET }}
|
|
5370
|
-
steps:
|
|
5349
|
+
contents: `# Scheduled jobs for the generated app. GitHub runs this on the schedules
|
|
5350
|
+
# below and it calls the app's /api/cron endpoint.
|
|
5351
|
+
#
|
|
5352
|
+
# Set two repository secrets before it does anything:
|
|
5353
|
+
# CRON_URL https://<your-app>/api/cron
|
|
5354
|
+
# CRON_SECRET the same value as the app's CRON_SECRET env var
|
|
5355
|
+
# Until then the job short-circuits, so CI stays green.
|
|
5356
|
+
name: Scheduled jobs
|
|
5357
|
+
|
|
5358
|
+
on:
|
|
5359
|
+
schedule:
|
|
5360
|
+
${schedules}
|
|
5361
|
+
workflow_dispatch:
|
|
5362
|
+
|
|
5363
|
+
jobs:
|
|
5364
|
+
cron:
|
|
5365
|
+
runs-on: ubuntu-latest
|
|
5366
|
+
if: \${{ secrets.CRON_URL != '' && secrets.CRON_SECRET != '' }}
|
|
5367
|
+
env:
|
|
5368
|
+
CRON_URL: \${{ secrets.CRON_URL }}
|
|
5369
|
+
CRON_SECRET: \${{ secrets.CRON_SECRET }}
|
|
5370
|
+
steps:
|
|
5371
5371
|
${steps}`,
|
|
5372
5372
|
}];
|
|
5373
5373
|
}
|
|
@@ -5423,37 +5423,37 @@ export function emitStudioFragment(project) {
|
|
|
5423
5423
|
const depList = Object.entries(deps).map(([name, v]) => ` ${name}@${v}`).join('\n');
|
|
5424
5424
|
const envKeys = envKeysUsed(allSource);
|
|
5425
5425
|
const libFiles = content.filter((f) => f.path.startsWith('src/lib/')).map((f) => `- \`${f.path}\``);
|
|
5426
|
-
const fragmentMd = `# Studio fragment - drop into your SvelteKit app
|
|
5427
|
-
|
|
5428
|
-
This folder holds ONLY app content - routes under \`src/routes\` and modules under
|
|
5429
|
-
\`src/lib\` (plus \`db/\` if present). It has no package.json, config, or app shell,
|
|
5430
|
-
so it drops into your existing SvelteKit app.
|
|
5431
|
-
|
|
5432
|
-
## 1. Copy the files
|
|
5433
|
-
Merge \`src/routes/*\` and \`src/lib/*\` into your app's \`src/\`. \`$lib\` is a stock
|
|
5434
|
-
SvelteKit alias, so imports resolve with no config. Watch for filename collisions
|
|
5435
|
-
with your own \`src/lib\` (e.g. \`schemas.ts\`, \`data.ts\`) - rename if needed.
|
|
5436
|
-
|
|
5437
|
-
## 2. Install dependencies
|
|
5438
|
-
\`\`\`bash
|
|
5439
|
-
npm install ${Object.keys(deps).filter((d) => d !== '@svgrid/grid' && d !== '@svgrid/enterprise').join(' ') || '@svgrid/grid @svgrid/enterprise'}
|
|
5440
|
-
\`\`\`
|
|
5441
|
-
Full list this fragment imports:
|
|
5442
|
-
\`\`\`
|
|
5443
|
-
${depList}
|
|
5444
|
-
\`\`\`
|
|
5445
|
-
|
|
5446
|
-
## 3. Styles
|
|
5447
|
-
Import \`src/app.css\` once (e.g. in your root \`+layout.svelte\`) - it carries the
|
|
5448
|
-
\`.st-*\` page styles the screens use and the \`--sg-*\` design tokens.
|
|
5449
|
-
|
|
5450
|
-
## 4. What your app must provide
|
|
5451
|
-
The full-app nav shell, home redirect, auth guard, RBAC bootstrap, i18n provider,
|
|
5452
|
-
and theme toggle live in the bundle's \`+layout.svelte\` - NOT here. Wire nav to the
|
|
5453
|
-
generated routes from your own layout.${envKeys.length ? `\n\n## Environment\nSet these where your app reads env:\n${envKeys.map((k) => `- \`${k}\``).join('\n')}` : ''}
|
|
5454
|
-
|
|
5455
|
-
## Files included
|
|
5456
|
-
${libFiles.join('\n')}
|
|
5426
|
+
const fragmentMd = `# Studio fragment - drop into your SvelteKit app
|
|
5427
|
+
|
|
5428
|
+
This folder holds ONLY app content - routes under \`src/routes\` and modules under
|
|
5429
|
+
\`src/lib\` (plus \`db/\` if present). It has no package.json, config, or app shell,
|
|
5430
|
+
so it drops into your existing SvelteKit app.
|
|
5431
|
+
|
|
5432
|
+
## 1. Copy the files
|
|
5433
|
+
Merge \`src/routes/*\` and \`src/lib/*\` into your app's \`src/\`. \`$lib\` is a stock
|
|
5434
|
+
SvelteKit alias, so imports resolve with no config. Watch for filename collisions
|
|
5435
|
+
with your own \`src/lib\` (e.g. \`schemas.ts\`, \`data.ts\`) - rename if needed.
|
|
5436
|
+
|
|
5437
|
+
## 2. Install dependencies
|
|
5438
|
+
\`\`\`bash
|
|
5439
|
+
npm install ${Object.keys(deps).filter((d) => d !== '@svgrid/grid' && d !== '@svgrid/enterprise').join(' ') || '@svgrid/grid @svgrid/enterprise'}
|
|
5440
|
+
\`\`\`
|
|
5441
|
+
Full list this fragment imports:
|
|
5442
|
+
\`\`\`
|
|
5443
|
+
${depList}
|
|
5444
|
+
\`\`\`
|
|
5445
|
+
|
|
5446
|
+
## 3. Styles
|
|
5447
|
+
Import \`src/app.css\` once (e.g. in your root \`+layout.svelte\`) - it carries the
|
|
5448
|
+
\`.st-*\` page styles the screens use and the \`--sg-*\` design tokens.
|
|
5449
|
+
|
|
5450
|
+
## 4. What your app must provide
|
|
5451
|
+
The full-app nav shell, home redirect, auth guard, RBAC bootstrap, i18n provider,
|
|
5452
|
+
and theme toggle live in the bundle's \`+layout.svelte\` - NOT here. Wire nav to the
|
|
5453
|
+
generated routes from your own layout.${envKeys.length ? `\n\n## Environment\nSet these where your app reads env:\n${envKeys.map((k) => `- \`${k}\``).join('\n')}` : ''}
|
|
5454
|
+
|
|
5455
|
+
## Files included
|
|
5456
|
+
${libFiles.join('\n')}
|
|
5457
5457
|
`;
|
|
5458
5458
|
return [
|
|
5459
5459
|
...content,
|