@prabhask5/stellar-engine 1.1.7 → 1.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -18
- package/dist/actions/remoteChange.d.ts +143 -18
- package/dist/actions/remoteChange.d.ts.map +1 -1
- package/dist/actions/remoteChange.js +182 -58
- package/dist/actions/remoteChange.js.map +1 -1
- package/dist/actions/truncateTooltip.d.ts +26 -12
- package/dist/actions/truncateTooltip.d.ts.map +1 -1
- package/dist/actions/truncateTooltip.js +89 -34
- package/dist/actions/truncateTooltip.js.map +1 -1
- package/dist/auth/crypto.d.ts +35 -8
- package/dist/auth/crypto.d.ts.map +1 -1
- package/dist/auth/crypto.js +38 -10
- package/dist/auth/crypto.js.map +1 -1
- package/dist/auth/deviceVerification.d.ts +236 -20
- package/dist/auth/deviceVerification.d.ts.map +1 -1
- package/dist/auth/deviceVerification.js +293 -40
- package/dist/auth/deviceVerification.js.map +1 -1
- package/dist/auth/displayUtils.d.ts +98 -0
- package/dist/auth/displayUtils.d.ts.map +1 -0
- package/dist/auth/displayUtils.js +133 -0
- package/dist/auth/displayUtils.js.map +1 -0
- package/dist/auth/loginGuard.d.ts +103 -16
- package/dist/auth/loginGuard.d.ts.map +1 -1
- package/dist/auth/loginGuard.js +163 -76
- package/dist/auth/loginGuard.js.map +1 -1
- package/dist/auth/offlineCredentials.d.ts +88 -24
- package/dist/auth/offlineCredentials.d.ts.map +1 -1
- package/dist/auth/offlineCredentials.js +114 -73
- package/dist/auth/offlineCredentials.js.map +1 -1
- package/dist/auth/offlineSession.d.ts +83 -9
- package/dist/auth/offlineSession.d.ts.map +1 -1
- package/dist/auth/offlineSession.js +104 -13
- package/dist/auth/offlineSession.js.map +1 -1
- package/dist/auth/resolveAuthState.d.ts +67 -9
- package/dist/auth/resolveAuthState.d.ts.map +1 -1
- package/dist/auth/resolveAuthState.js +125 -71
- package/dist/auth/resolveAuthState.js.map +1 -1
- package/dist/auth/singleUser.d.ts +390 -37
- package/dist/auth/singleUser.d.ts.map +1 -1
- package/dist/auth/singleUser.js +504 -103
- package/dist/auth/singleUser.js.map +1 -1
- package/dist/bin/install-pwa.d.ts +18 -2
- package/dist/bin/install-pwa.d.ts.map +1 -1
- package/dist/bin/install-pwa.js +2624 -77
- package/dist/bin/install-pwa.js.map +1 -1
- package/dist/config.d.ts +131 -15
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +87 -9
- package/dist/config.js.map +1 -1
- package/dist/conflicts.d.ts +246 -23
- package/dist/conflicts.d.ts.map +1 -1
- package/dist/conflicts.js +495 -46
- package/dist/conflicts.js.map +1 -1
- package/dist/data.d.ts +338 -18
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +385 -34
- package/dist/data.js.map +1 -1
- package/dist/database.d.ts +72 -14
- package/dist/database.d.ts.map +1 -1
- package/dist/database.js +120 -29
- package/dist/database.js.map +1 -1
- package/dist/debug.d.ts +77 -1
- package/dist/debug.d.ts.map +1 -1
- package/dist/debug.js +88 -1
- package/dist/debug.js.map +1 -1
- package/dist/deviceId.d.ts +38 -7
- package/dist/deviceId.d.ts.map +1 -1
- package/dist/deviceId.js +68 -10
- package/dist/deviceId.js.map +1 -1
- package/dist/engine.d.ts +175 -3
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +756 -109
- package/dist/engine.js.map +1 -1
- package/dist/entries/actions.d.ts +13 -0
- package/dist/entries/actions.d.ts.map +1 -1
- package/dist/entries/actions.js +26 -1
- package/dist/entries/actions.js.map +1 -1
- package/dist/entries/auth.d.ts +15 -4
- package/dist/entries/auth.d.ts.map +1 -1
- package/dist/entries/auth.js +47 -4
- package/dist/entries/auth.js.map +1 -1
- package/dist/entries/config.d.ts +12 -0
- package/dist/entries/config.d.ts.map +1 -1
- package/dist/entries/config.js +18 -1
- package/dist/entries/config.js.map +1 -1
- package/dist/entries/kit.d.ts +11 -0
- package/dist/entries/kit.d.ts.map +1 -1
- package/dist/entries/kit.js +52 -2
- package/dist/entries/kit.js.map +1 -1
- package/dist/entries/stores.d.ts +11 -0
- package/dist/entries/stores.d.ts.map +1 -1
- package/dist/entries/stores.js +43 -2
- package/dist/entries/stores.js.map +1 -1
- package/dist/entries/types.d.ts +10 -1
- package/dist/entries/types.d.ts.map +1 -1
- package/dist/entries/types.js +10 -0
- package/dist/entries/types.js.map +1 -1
- package/dist/entries/utils.d.ts +6 -0
- package/dist/entries/utils.d.ts.map +1 -1
- package/dist/entries/utils.js +22 -1
- package/dist/entries/utils.js.map +1 -1
- package/dist/entries/vite.d.ts +17 -0
- package/dist/entries/vite.d.ts.map +1 -1
- package/dist/entries/vite.js +24 -1
- package/dist/entries/vite.js.map +1 -1
- package/dist/index.d.ts +32 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +166 -23
- package/dist/index.js.map +1 -1
- package/dist/kit/auth.d.ts +60 -5
- package/dist/kit/auth.d.ts.map +1 -1
- package/dist/kit/auth.js +45 -4
- package/dist/kit/auth.js.map +1 -1
- package/dist/kit/confirm.d.ts +93 -12
- package/dist/kit/confirm.d.ts.map +1 -1
- package/dist/kit/confirm.js +103 -16
- package/dist/kit/confirm.js.map +1 -1
- package/dist/kit/loads.d.ts +148 -23
- package/dist/kit/loads.d.ts.map +1 -1
- package/dist/kit/loads.js +136 -28
- package/dist/kit/loads.js.map +1 -1
- package/dist/kit/server.d.ts +142 -10
- package/dist/kit/server.d.ts.map +1 -1
- package/dist/kit/server.js +158 -15
- package/dist/kit/server.js.map +1 -1
- package/dist/kit/sw.d.ts +152 -23
- package/dist/kit/sw.d.ts.map +1 -1
- package/dist/kit/sw.js +182 -26
- package/dist/kit/sw.js.map +1 -1
- package/dist/queue.d.ts +274 -0
- package/dist/queue.d.ts.map +1 -1
- package/dist/queue.js +556 -38
- package/dist/queue.js.map +1 -1
- package/dist/realtime.d.ts +241 -27
- package/dist/realtime.d.ts.map +1 -1
- package/dist/realtime.js +633 -109
- package/dist/realtime.js.map +1 -1
- package/dist/runtime/runtimeConfig.d.ts +91 -8
- package/dist/runtime/runtimeConfig.d.ts.map +1 -1
- package/dist/runtime/runtimeConfig.js +146 -19
- package/dist/runtime/runtimeConfig.js.map +1 -1
- package/dist/stores/authState.d.ts +150 -11
- package/dist/stores/authState.d.ts.map +1 -1
- package/dist/stores/authState.js +169 -17
- package/dist/stores/authState.js.map +1 -1
- package/dist/stores/network.d.ts +39 -0
- package/dist/stores/network.d.ts.map +1 -1
- package/dist/stores/network.js +169 -16
- package/dist/stores/network.js.map +1 -1
- package/dist/stores/remoteChanges.d.ts +327 -52
- package/dist/stores/remoteChanges.d.ts.map +1 -1
- package/dist/stores/remoteChanges.js +337 -75
- package/dist/stores/remoteChanges.js.map +1 -1
- package/dist/stores/sync.d.ts +130 -0
- package/dist/stores/sync.d.ts.map +1 -1
- package/dist/stores/sync.js +167 -7
- package/dist/stores/sync.js.map +1 -1
- package/dist/supabase/auth.d.ts +186 -46
- package/dist/supabase/auth.d.ts.map +1 -1
- package/dist/supabase/auth.js +238 -190
- package/dist/supabase/auth.js.map +1 -1
- package/dist/supabase/client.d.ts +79 -6
- package/dist/supabase/client.d.ts.map +1 -1
- package/dist/supabase/client.js +158 -15
- package/dist/supabase/client.js.map +1 -1
- package/dist/supabase/validate.d.ts +101 -7
- package/dist/supabase/validate.d.ts.map +1 -1
- package/dist/supabase/validate.js +117 -8
- package/dist/supabase/validate.js.map +1 -1
- package/dist/sw/build/vite-plugin.d.ts +55 -10
- package/dist/sw/build/vite-plugin.d.ts.map +1 -1
- package/dist/sw/build/vite-plugin.js +77 -18
- package/dist/sw/build/vite-plugin.js.map +1 -1
- package/dist/sw/sw.js +99 -44
- package/dist/types.d.ts +150 -26
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +12 -10
- package/dist/types.js.map +1 -1
- package/dist/utils.d.ts +55 -13
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +83 -22
- package/dist/utils.js.map +1 -1
- package/package.json +1 -1
- package/dist/auth/admin.d.ts +0 -12
- package/dist/auth/admin.d.ts.map +0 -1
- package/dist/auth/admin.js +0 -26
- package/dist/auth/admin.js.map +0 -1
- package/dist/auth/offlineLogin.d.ts +0 -34
- package/dist/auth/offlineLogin.d.ts.map +0 -1
- package/dist/auth/offlineLogin.js +0 -75
- package/dist/auth/offlineLogin.js.map +0 -1
package/dist/bin/install-pwa.js
CHANGED
|
@@ -2,8 +2,24 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* @fileoverview CLI script that scaffolds a PWA SvelteKit project using stellar-engine.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Generates a complete project structure including:
|
|
6
|
+
* - Build configuration (Vite, TypeScript, SvelteKit, ESLint, Prettier, Knip)
|
|
7
|
+
* - PWA assets (manifest, offline page, placeholder icons)
|
|
8
|
+
* - SvelteKit routes (home, login, setup wizard, profile, error, confirm)
|
|
9
|
+
* - API endpoints (config, deploy, validate)
|
|
10
|
+
* - Supabase database schema
|
|
11
|
+
* - Git hooks via Husky
|
|
12
|
+
*
|
|
13
|
+
* Files are written non-destructively: existing files are skipped, not overwritten.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```bash
|
|
17
|
+
* stellar-engine install pwa --name "App Name" --short_name "Short" --prefix "myprefix" [--description "..."]
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* @see {@link main} for the entry point
|
|
21
|
+
* @see {@link parseArgs} for CLI argument parsing
|
|
22
|
+
* @see {@link writeIfMissing} for the non-destructive file write strategy
|
|
7
23
|
*/
|
|
8
24
|
import { writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
9
25
|
import { join } from 'path';
|
|
@@ -12,7 +28,15 @@ import { execSync } from 'child_process';
|
|
|
12
28
|
// HELPERS
|
|
13
29
|
// =============================================================================
|
|
14
30
|
/**
|
|
15
|
-
* Writes a file only if it doesn't already exist
|
|
31
|
+
* Writes a file only if it doesn't already exist (non-destructive).
|
|
32
|
+
*
|
|
33
|
+
* Creates parent directories as needed. Tracks created and skipped files
|
|
34
|
+
* in the provided arrays for the final summary output.
|
|
35
|
+
*
|
|
36
|
+
* @param filePath - Absolute path to the target file.
|
|
37
|
+
* @param content - The file content to write.
|
|
38
|
+
* @param createdFiles - Accumulator for newly-created file paths (relative).
|
|
39
|
+
* @param skippedFiles - Accumulator for skipped file paths (relative).
|
|
16
40
|
*/
|
|
17
41
|
function writeIfMissing(filePath, content, createdFiles, skippedFiles) {
|
|
18
42
|
const relPath = filePath.replace(process.cwd() + '/', '');
|
|
@@ -33,6 +57,19 @@ function writeIfMissing(filePath, content, createdFiles, skippedFiles) {
|
|
|
33
57
|
// =============================================================================
|
|
34
58
|
// ARG PARSING
|
|
35
59
|
// =============================================================================
|
|
60
|
+
/**
|
|
61
|
+
* Parse command-line arguments into an {@link InstallOptions} object.
|
|
62
|
+
*
|
|
63
|
+
* Expects the format:
|
|
64
|
+
* `stellar-engine install pwa --name "..." --short_name "..." --prefix "..." [--description "..."]`
|
|
65
|
+
*
|
|
66
|
+
* Exits the process with a usage message if required arguments are missing.
|
|
67
|
+
*
|
|
68
|
+
* @param argv - The raw `process.argv` array.
|
|
69
|
+
* @returns The parsed {@link InstallOptions}.
|
|
70
|
+
*
|
|
71
|
+
* @throws {SystemExit} Exits with code 1 if `--name`, `--short_name`, or `--prefix` are missing.
|
|
72
|
+
*/
|
|
36
73
|
function parseArgs(argv) {
|
|
37
74
|
const args = argv.slice(2);
|
|
38
75
|
if (args[0] !== 'install' || args[1] !== 'pwa') {
|
|
@@ -64,12 +101,26 @@ function parseArgs(argv) {
|
|
|
64
101
|
'Usage: stellar-engine install pwa --name "App Name" --short_name "Short" --prefix "myprefix" [--description "..."]');
|
|
65
102
|
process.exit(1);
|
|
66
103
|
}
|
|
104
|
+
/* Derive kebab-case name for package.json from the full name */
|
|
67
105
|
const kebabName = name.toLowerCase().replace(/\s+/g, '-');
|
|
68
106
|
return { name, shortName, prefix, description, kebabName };
|
|
69
107
|
}
|
|
70
108
|
// =============================================================================
|
|
71
109
|
// TEMPLATE GENERATORS
|
|
72
110
|
// =============================================================================
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// PACKAGE.JSON GENERATOR
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
/**
|
|
115
|
+
* Generate a `package.json` with all dependencies and scripts pre-configured
|
|
116
|
+
* for a stellar-engine PWA project.
|
|
117
|
+
*
|
|
118
|
+
* Includes dev tooling (ESLint, Prettier, Knip, Husky, svelte-check) and
|
|
119
|
+
* the `@prabhask5/stellar-engine` runtime dependency.
|
|
120
|
+
*
|
|
121
|
+
* @param opts - The install options containing the kebab-cased project name.
|
|
122
|
+
* @returns The JSON string for `package.json`.
|
|
123
|
+
*/
|
|
73
124
|
function generatePackageJson(opts) {
|
|
74
125
|
return (JSON.stringify({
|
|
75
126
|
name: opts.kebabName,
|
|
@@ -115,6 +166,16 @@ function generatePackageJson(opts) {
|
|
|
115
166
|
type: 'module'
|
|
116
167
|
}, null, 2) + '\n');
|
|
117
168
|
}
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// VITE CONFIG GENERATOR
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
/**
|
|
173
|
+
* Generate a Vite config with SvelteKit and stellarPWA plugins, plus
|
|
174
|
+
* manual chunk-splitting for heavy vendor libraries.
|
|
175
|
+
*
|
|
176
|
+
* @param opts - The install options containing `prefix` and `name`.
|
|
177
|
+
* @returns The TypeScript source for `vite.config.ts`.
|
|
178
|
+
*/
|
|
118
179
|
function generateViteConfig(opts) {
|
|
119
180
|
return `/**
|
|
120
181
|
* @fileoverview Vite build configuration for the ${opts.shortName} PWA.
|
|
@@ -128,10 +189,18 @@ function generateViteConfig(opts) {
|
|
|
128
189
|
* into their own bundles for long-term caching
|
|
129
190
|
*/
|
|
130
191
|
|
|
192
|
+
// =============================================================================
|
|
193
|
+
// IMPORTS
|
|
194
|
+
// =============================================================================
|
|
195
|
+
|
|
131
196
|
import { sveltekit } from '@sveltejs/kit/vite';
|
|
132
197
|
import { stellarPWA } from '@prabhask5/stellar-engine/vite';
|
|
133
198
|
import { defineConfig } from 'vite';
|
|
134
199
|
|
|
200
|
+
// =============================================================================
|
|
201
|
+
// VITE CONFIGURATION
|
|
202
|
+
// =============================================================================
|
|
203
|
+
|
|
135
204
|
export default defineConfig({
|
|
136
205
|
plugins: [
|
|
137
206
|
sveltekit(),
|
|
@@ -140,21 +209,35 @@ export default defineConfig({
|
|
|
140
209
|
build: {
|
|
141
210
|
rollupOptions: {
|
|
142
211
|
output: {
|
|
212
|
+
/* ── Vendor chunk isolation ── */
|
|
143
213
|
manualChunks: (id) => {
|
|
144
214
|
if (id.includes('node_modules')) {
|
|
215
|
+
/** Supabase auth + realtime — ~100 KB gzipped */
|
|
145
216
|
if (id.includes('@supabase')) return 'vendor-supabase';
|
|
217
|
+
/** Dexie (IndexedDB wrapper) — offline-first storage layer */
|
|
146
218
|
if (id.includes('dexie')) return 'vendor-dexie';
|
|
147
219
|
}
|
|
148
220
|
}
|
|
149
221
|
}
|
|
150
222
|
},
|
|
223
|
+
/** Reduce noise — only warn for chunks above 500 KB */
|
|
151
224
|
chunkSizeWarningLimit: 500,
|
|
225
|
+
/** esbuild is faster than terser and produces comparable output */
|
|
152
226
|
minify: 'esbuild',
|
|
227
|
+
/** Target modern browsers → enables smaller output (no legacy polyfills) */
|
|
153
228
|
target: 'es2020'
|
|
154
229
|
}
|
|
155
230
|
});
|
|
156
231
|
`;
|
|
157
232
|
}
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// TSCONFIG GENERATOR
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
/**
|
|
237
|
+
* Generate a `tsconfig.json` extending SvelteKit's generated config.
|
|
238
|
+
*
|
|
239
|
+
* @returns The JSON string for `tsconfig.json`.
|
|
240
|
+
*/
|
|
158
241
|
function generateTsconfig() {
|
|
159
242
|
return (JSON.stringify({
|
|
160
243
|
extends: './.svelte-kit/tsconfig.json',
|
|
@@ -171,6 +254,15 @@ function generateTsconfig() {
|
|
|
171
254
|
}
|
|
172
255
|
}, null, 2) + '\n');
|
|
173
256
|
}
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// SVELTE CONFIG GENERATOR
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
/**
|
|
261
|
+
* Generate a `svelte.config.js` with adapter-auto and vitePreprocess.
|
|
262
|
+
*
|
|
263
|
+
* @param opts - The install options containing `shortName`.
|
|
264
|
+
* @returns The JavaScript source for `svelte.config.js`.
|
|
265
|
+
*/
|
|
174
266
|
function generateSvelteConfig(opts) {
|
|
175
267
|
return `/**
|
|
176
268
|
* @fileoverview SvelteKit project configuration for ${opts.shortName}.
|
|
@@ -213,6 +305,15 @@ const config = {
|
|
|
213
305
|
export default config;
|
|
214
306
|
`;
|
|
215
307
|
}
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// MANIFEST GENERATOR
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
/**
|
|
312
|
+
* Generate a PWA `manifest.json` with icons, theme colours, and display settings.
|
|
313
|
+
*
|
|
314
|
+
* @param opts - The install options containing `name`, `shortName`, and `description`.
|
|
315
|
+
* @returns The JSON string for `static/manifest.json`.
|
|
316
|
+
*/
|
|
216
317
|
function generateManifest(opts) {
|
|
217
318
|
return (JSON.stringify({
|
|
218
319
|
name: opts.name,
|
|
@@ -243,6 +344,15 @@ function generateManifest(opts) {
|
|
|
243
344
|
prefer_related_applications: false
|
|
244
345
|
}, null, 2) + '\n');
|
|
245
346
|
}
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// APP.D.TS GENERATOR
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
/**
|
|
351
|
+
* Generate the SvelteKit ambient type declarations file (`src/app.d.ts`).
|
|
352
|
+
*
|
|
353
|
+
* @param opts - The install options containing `shortName`.
|
|
354
|
+
* @returns The TypeScript source for `src/app.d.ts`.
|
|
355
|
+
*/
|
|
246
356
|
function generateAppDts(opts) {
|
|
247
357
|
return `/**
|
|
248
358
|
* @fileoverview Ambient type declarations for the ${opts.shortName} SvelteKit application.
|
|
@@ -289,6 +399,17 @@ declare global {
|
|
|
289
399
|
export {};
|
|
290
400
|
`;
|
|
291
401
|
}
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
// APP.HTML GENERATOR
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
/**
|
|
406
|
+
* Generate the root HTML shell (`src/app.html`) with PWA meta tags, iOS
|
|
407
|
+
* configuration, landscape blocker, gesture prevention, and deferred
|
|
408
|
+
* service worker registration.
|
|
409
|
+
*
|
|
410
|
+
* @param opts - The install options containing `name`, `shortName`, and `description`.
|
|
411
|
+
* @returns The HTML source for `src/app.html`.
|
|
412
|
+
*/
|
|
292
413
|
function generateAppHtml(opts) {
|
|
293
414
|
return `<!doctype html>
|
|
294
415
|
<html lang="en">
|
|
@@ -540,12 +661,50 @@ function generateAppHtml(opts) {
|
|
|
540
661
|
</html>
|
|
541
662
|
`;
|
|
542
663
|
}
|
|
664
|
+
// ---------------------------------------------------------------------------
|
|
665
|
+
// README GENERATOR
|
|
666
|
+
// ---------------------------------------------------------------------------
|
|
667
|
+
/**
|
|
668
|
+
* Generate a minimal `README.md` with project name, links to architecture
|
|
669
|
+
* docs, and a quick-reference script table.
|
|
670
|
+
*
|
|
671
|
+
* @param opts - The install options containing `name`.
|
|
672
|
+
* @returns The Markdown source for `README.md`.
|
|
673
|
+
*/
|
|
543
674
|
function generateReadme(opts) {
|
|
544
675
|
return `# ${opts.name}
|
|
545
676
|
|
|
546
677
|
> See [ARCHITECTURE.md](./ARCHITECTURE.md) for project structure.
|
|
547
678
|
> See [FRAMEWORKS.md](./FRAMEWORKS.md) for framework decisions.
|
|
548
679
|
|
|
680
|
+
## Install as an App
|
|
681
|
+
|
|
682
|
+
This is a PWA (Progressive Web App) — install it on any device for quick access and an app-like experience.
|
|
683
|
+
|
|
684
|
+
### iOS (Safari)
|
|
685
|
+
|
|
686
|
+
1. Open the app in **Safari**.
|
|
687
|
+
2. Tap the **Share** button (square with arrow).
|
|
688
|
+
3. Scroll down and tap **Add to Home Screen**.
|
|
689
|
+
4. Tap **Add**.
|
|
690
|
+
|
|
691
|
+
### Android (Chrome)
|
|
692
|
+
|
|
693
|
+
1. Open the app in **Chrome**.
|
|
694
|
+
2. Tap the **three-dot menu** (top right).
|
|
695
|
+
3. Tap **Add to Home screen** or **Install app**.
|
|
696
|
+
4. Confirm the installation.
|
|
697
|
+
|
|
698
|
+
### Desktop (Chrome / Edge)
|
|
699
|
+
|
|
700
|
+
1. Open the app in your browser.
|
|
701
|
+
2. Click the **install icon** in the address bar (or look for an install prompt).
|
|
702
|
+
3. Click **Install**.
|
|
703
|
+
|
|
704
|
+
Once installed, the app runs as a standalone window with full offline support.
|
|
705
|
+
|
|
706
|
+
---
|
|
707
|
+
|
|
549
708
|
## Getting Started
|
|
550
709
|
|
|
551
710
|
\`\`\`bash
|
|
@@ -566,6 +725,15 @@ npm run dev
|
|
|
566
725
|
| \`npm run validate\` | Full validation (check + lint + dead-code) |
|
|
567
726
|
`;
|
|
568
727
|
}
|
|
728
|
+
// ---------------------------------------------------------------------------
|
|
729
|
+
// ARCHITECTURE DOC GENERATOR
|
|
730
|
+
// ---------------------------------------------------------------------------
|
|
731
|
+
/**
|
|
732
|
+
* Generate an `ARCHITECTURE.md` describing the project stack and directory layout.
|
|
733
|
+
*
|
|
734
|
+
* @param opts - The install options containing `name`.
|
|
735
|
+
* @returns The Markdown source for `ARCHITECTURE.md`.
|
|
736
|
+
*/
|
|
569
737
|
function generateArchitecture(opts) {
|
|
570
738
|
return `# Architecture
|
|
571
739
|
|
|
@@ -595,6 +763,14 @@ static/
|
|
|
595
763
|
\`\`\`
|
|
596
764
|
`;
|
|
597
765
|
}
|
|
766
|
+
// ---------------------------------------------------------------------------
|
|
767
|
+
// FRAMEWORKS DOC GENERATOR
|
|
768
|
+
// ---------------------------------------------------------------------------
|
|
769
|
+
/**
|
|
770
|
+
* Generate a `FRAMEWORKS.md` documenting technology choices and rationale.
|
|
771
|
+
*
|
|
772
|
+
* @returns The Markdown source for `FRAMEWORKS.md`.
|
|
773
|
+
*/
|
|
598
774
|
function generateFrameworks() {
|
|
599
775
|
return `# Framework Decisions
|
|
600
776
|
|
|
@@ -625,6 +801,15 @@ Service worker (generated by \`stellarPWA\` Vite plugin) with:
|
|
|
625
801
|
- **Husky** — pre-commit hooks
|
|
626
802
|
`;
|
|
627
803
|
}
|
|
804
|
+
// ---------------------------------------------------------------------------
|
|
805
|
+
// GITIGNORE GENERATOR
|
|
806
|
+
// ---------------------------------------------------------------------------
|
|
807
|
+
/**
|
|
808
|
+
* Generate a `.gitignore` tailored for SvelteKit PWA projects.
|
|
809
|
+
* Excludes build artifacts, generated SW files, and environment secrets.
|
|
810
|
+
*
|
|
811
|
+
* @returns The gitignore content string.
|
|
812
|
+
*/
|
|
628
813
|
function generateGitignore() {
|
|
629
814
|
return `node_modules
|
|
630
815
|
.DS_Store
|
|
@@ -640,6 +825,16 @@ static/sw.js
|
|
|
640
825
|
static/asset-manifest.json
|
|
641
826
|
`;
|
|
642
827
|
}
|
|
828
|
+
// ---------------------------------------------------------------------------
|
|
829
|
+
// OFFLINE HTML GENERATOR
|
|
830
|
+
// ---------------------------------------------------------------------------
|
|
831
|
+
/**
|
|
832
|
+
* Generate a placeholder offline fallback page (`static/offline.html`).
|
|
833
|
+
* The service worker serves this when no cached HTML is available.
|
|
834
|
+
*
|
|
835
|
+
* @param opts - The install options containing `name`.
|
|
836
|
+
* @returns The HTML source for `static/offline.html`.
|
|
837
|
+
*/
|
|
643
838
|
function generateOfflineHtml(opts) {
|
|
644
839
|
return `<!-- TODO: Customize this offline fallback page with your app's branding.
|
|
645
840
|
This page is served by the service worker when the app is offline and
|
|
@@ -661,6 +856,18 @@ function generateOfflineHtml(opts) {
|
|
|
661
856
|
</html>
|
|
662
857
|
`;
|
|
663
858
|
}
|
|
859
|
+
// ---------------------------------------------------------------------------
|
|
860
|
+
// PLACEHOLDER SVG GENERATORS
|
|
861
|
+
// ---------------------------------------------------------------------------
|
|
862
|
+
/**
|
|
863
|
+
* Generate a placeholder app icon SVG with a coloured background and
|
|
864
|
+
* a centred text label (typically a single letter).
|
|
865
|
+
*
|
|
866
|
+
* @param color - The background fill colour (e.g., `'#6c5ce7'`).
|
|
867
|
+
* @param label - The text to display (e.g., `'M'` for "My App").
|
|
868
|
+
* @param fontSize - The font size for the label (default: 64).
|
|
869
|
+
* @returns The SVG markup string.
|
|
870
|
+
*/
|
|
664
871
|
function generatePlaceholderSvg(color, label, fontSize = 64) {
|
|
665
872
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
666
873
|
<rect width="512" height="512" rx="64" fill="${color}"/>
|
|
@@ -668,6 +875,13 @@ function generatePlaceholderSvg(color, label, fontSize = 64) {
|
|
|
668
875
|
</svg>
|
|
669
876
|
`;
|
|
670
877
|
}
|
|
878
|
+
/**
|
|
879
|
+
* Generate a monochrome (white background, black text) icon SVG.
|
|
880
|
+
* Used for the `monochrome` icon variant in the PWA manifest.
|
|
881
|
+
*
|
|
882
|
+
* @param label - The text to display.
|
|
883
|
+
* @returns The SVG markup string.
|
|
884
|
+
*/
|
|
671
885
|
function generateMonochromeSvg(label) {
|
|
672
886
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
673
887
|
<rect width="512" height="512" rx="64" fill="#ffffff"/>
|
|
@@ -675,6 +889,12 @@ function generateMonochromeSvg(label) {
|
|
|
675
889
|
</svg>
|
|
676
890
|
`;
|
|
677
891
|
}
|
|
892
|
+
/**
|
|
893
|
+
* Generate a splash screen SVG with a dark background and the app's short name.
|
|
894
|
+
*
|
|
895
|
+
* @param label - The text to display (typically `shortName`).
|
|
896
|
+
* @returns The SVG markup string.
|
|
897
|
+
*/
|
|
678
898
|
function generateSplashSvg(label) {
|
|
679
899
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
680
900
|
<rect width="512" height="512" rx="64" fill="#0f0f1a"/>
|
|
@@ -682,6 +902,15 @@ function generateSplashSvg(label) {
|
|
|
682
902
|
</svg>
|
|
683
903
|
`;
|
|
684
904
|
}
|
|
905
|
+
// ---------------------------------------------------------------------------
|
|
906
|
+
// EMAIL TEMPLATE PLACEHOLDER
|
|
907
|
+
// ---------------------------------------------------------------------------
|
|
908
|
+
/**
|
|
909
|
+
* Generate a placeholder HTML email template with a TODO comment.
|
|
910
|
+
*
|
|
911
|
+
* @param title - The email template title (e.g., `"Change Email"`).
|
|
912
|
+
* @returns The HTML source for the email placeholder.
|
|
913
|
+
*/
|
|
685
914
|
function generateEmailPlaceholder(title) {
|
|
686
915
|
return `<!-- TODO: ${title} email template -->
|
|
687
916
|
<!-- See stellar-engine EMAIL_TEMPLATES.md for the full template format -->
|
|
@@ -691,6 +920,16 @@ function generateEmailPlaceholder(title) {
|
|
|
691
920
|
</html>
|
|
692
921
|
`;
|
|
693
922
|
}
|
|
923
|
+
// ---------------------------------------------------------------------------
|
|
924
|
+
// SUPABASE SCHEMA GENERATOR
|
|
925
|
+
// ---------------------------------------------------------------------------
|
|
926
|
+
/**
|
|
927
|
+
* Generate the Supabase database schema SQL including helper functions,
|
|
928
|
+
* the `trusted_devices` table, and commented-out example table patterns.
|
|
929
|
+
*
|
|
930
|
+
* @param opts - The install options containing `name`.
|
|
931
|
+
* @returns The SQL source for `supabase-schema.sql`.
|
|
932
|
+
*/
|
|
694
933
|
function generateSupabaseSchema(opts) {
|
|
695
934
|
return `-- ${opts.name} Database Schema for Supabase
|
|
696
935
|
-- Copy and paste this entire file into your Supabase SQL Editor
|
|
@@ -783,6 +1022,14 @@ create index idx_trusted_devices_user_id on trusted_devices(user_id);
|
|
|
783
1022
|
alter publication supabase_realtime add table trusted_devices;
|
|
784
1023
|
`;
|
|
785
1024
|
}
|
|
1025
|
+
// ---------------------------------------------------------------------------
|
|
1026
|
+
// ESLINT CONFIG GENERATOR
|
|
1027
|
+
// ---------------------------------------------------------------------------
|
|
1028
|
+
/**
|
|
1029
|
+
* Generate an ESLint flat config with TypeScript and Svelte support.
|
|
1030
|
+
*
|
|
1031
|
+
* @returns The JavaScript source for `eslint.config.js`.
|
|
1032
|
+
*/
|
|
786
1033
|
function generateEslintConfig() {
|
|
787
1034
|
return `import js from '@eslint/js';
|
|
788
1035
|
import ts from 'typescript-eslint';
|
|
@@ -856,6 +1103,14 @@ export default [
|
|
|
856
1103
|
];
|
|
857
1104
|
`;
|
|
858
1105
|
}
|
|
1106
|
+
// ---------------------------------------------------------------------------
|
|
1107
|
+
// PRETTIER CONFIG GENERATORS
|
|
1108
|
+
// ---------------------------------------------------------------------------
|
|
1109
|
+
/**
|
|
1110
|
+
* Generate a `.prettierrc` with SvelteKit-friendly defaults.
|
|
1111
|
+
*
|
|
1112
|
+
* @returns The JSON string for `.prettierrc`.
|
|
1113
|
+
*/
|
|
859
1114
|
function generatePrettierrc() {
|
|
860
1115
|
return (JSON.stringify({
|
|
861
1116
|
useTabs: false,
|
|
@@ -874,6 +1129,11 @@ function generatePrettierrc() {
|
|
|
874
1129
|
]
|
|
875
1130
|
}, null, 2) + '\n');
|
|
876
1131
|
}
|
|
1132
|
+
/**
|
|
1133
|
+
* Generate a `.prettierignore` excluding build artifacts and generated files.
|
|
1134
|
+
*
|
|
1135
|
+
* @returns The prettierignore content string.
|
|
1136
|
+
*/
|
|
877
1137
|
function generatePrettierignore() {
|
|
878
1138
|
return `.svelte-kit
|
|
879
1139
|
build
|
|
@@ -884,6 +1144,14 @@ static
|
|
|
884
1144
|
package-lock.json
|
|
885
1145
|
`;
|
|
886
1146
|
}
|
|
1147
|
+
// ---------------------------------------------------------------------------
|
|
1148
|
+
// KNIP CONFIG GENERATOR
|
|
1149
|
+
// ---------------------------------------------------------------------------
|
|
1150
|
+
/**
|
|
1151
|
+
* Generate a `knip.json` for dead code detection in a SvelteKit project.
|
|
1152
|
+
*
|
|
1153
|
+
* @returns The JSON string for `knip.json`.
|
|
1154
|
+
*/
|
|
887
1155
|
function generateKnipJson() {
|
|
888
1156
|
return (JSON.stringify({
|
|
889
1157
|
$schema: 'https://unpkg.com/knip@latest/schema.json',
|
|
@@ -895,12 +1163,42 @@ function generateKnipJson() {
|
|
|
895
1163
|
}
|
|
896
1164
|
}, null, 2) + '\n');
|
|
897
1165
|
}
|
|
1166
|
+
// ---------------------------------------------------------------------------
|
|
1167
|
+
// HUSKY PRE-COMMIT GENERATOR
|
|
1168
|
+
// ---------------------------------------------------------------------------
|
|
1169
|
+
/**
|
|
1170
|
+
* Generate the Husky pre-commit hook script that runs cleanup and validation.
|
|
1171
|
+
*
|
|
1172
|
+
* @returns The shell script content for `.husky/pre-commit`.
|
|
1173
|
+
*/
|
|
898
1174
|
function generateHuskyPreCommit() {
|
|
899
1175
|
return `npm run cleanup && npm run validate && git add -u
|
|
900
1176
|
`;
|
|
901
1177
|
}
|
|
1178
|
+
// ---------------------------------------------------------------------------
|
|
1179
|
+
// ROOT LAYOUT GENERATORS
|
|
1180
|
+
// ---------------------------------------------------------------------------
|
|
1181
|
+
/**
|
|
1182
|
+
* Generate the root `+layout.ts` with runtime config initialisation,
|
|
1183
|
+
* auth state resolution, and sync engine startup.
|
|
1184
|
+
*
|
|
1185
|
+
* @param opts - The install options containing `name` and `prefix`.
|
|
1186
|
+
* @returns The TypeScript source for `src/routes/+layout.ts`.
|
|
1187
|
+
*/
|
|
902
1188
|
function generateRootLayoutTs(opts) {
|
|
903
|
-
return
|
|
1189
|
+
return `/**
|
|
1190
|
+
* @fileoverview Root layout loader — engine bootstrap + auth resolution.
|
|
1191
|
+
*
|
|
1192
|
+
* Runs on every navigation. In the browser it initialises runtime config,
|
|
1193
|
+
* resolves the current auth state (online session or offline credentials),
|
|
1194
|
+
* and starts the sync engine when the user is authenticated.
|
|
1195
|
+
*/
|
|
1196
|
+
|
|
1197
|
+
// =============================================================================
|
|
1198
|
+
// IMPORTS
|
|
1199
|
+
// =============================================================================
|
|
1200
|
+
|
|
1201
|
+
import { browser } from '$app/environment';
|
|
904
1202
|
import { redirect } from '@sveltejs/kit';
|
|
905
1203
|
import { goto } from '$app/navigation';
|
|
906
1204
|
import { initEngine, startSyncEngine, supabase } from '@prabhask5/stellar-engine';
|
|
@@ -910,9 +1208,19 @@ import { resolveRootLayout } from '@prabhask5/stellar-engine/kit';
|
|
|
910
1208
|
import type { AuthMode, OfflineCredentials, Session } from '@prabhask5/stellar-engine/types';
|
|
911
1209
|
import type { LayoutLoad } from './$types';
|
|
912
1210
|
|
|
1211
|
+
// =============================================================================
|
|
1212
|
+
// SVELTEKIT ROUTE CONFIG
|
|
1213
|
+
// =============================================================================
|
|
1214
|
+
|
|
1215
|
+
/** Allow server-side rendering for initial page load performance. */
|
|
913
1216
|
export const ssr = true;
|
|
1217
|
+
/** Disable prerendering — pages depend on runtime auth state. */
|
|
914
1218
|
export const prerender = false;
|
|
915
1219
|
|
|
1220
|
+
// =============================================================================
|
|
1221
|
+
// ENGINE BOOTSTRAP
|
|
1222
|
+
// =============================================================================
|
|
1223
|
+
|
|
916
1224
|
// TODO: Configure initEngine() with your app-specific database schema.
|
|
917
1225
|
// Call initEngine({...}) at module scope (guarded by \`if (browser)\`).
|
|
918
1226
|
// See the stellar-engine documentation for the full config interface.
|
|
@@ -931,21 +1239,43 @@ export const prerender = false;
|
|
|
931
1239
|
// },
|
|
932
1240
|
// supabase,
|
|
933
1241
|
// prefix: '${opts.prefix}',
|
|
934
|
-
// auth: {
|
|
1242
|
+
// auth: { singleUser: { gateType: 'code', codeLength: 6 } },
|
|
935
1243
|
// onAuthStateChange: (event, session) => { /* handle auth events */ },
|
|
936
1244
|
// onAuthKicked: async () => { await lockSingleUser(); goto('/login'); }
|
|
937
1245
|
// });
|
|
938
1246
|
// }
|
|
939
1247
|
|
|
1248
|
+
// =============================================================================
|
|
1249
|
+
// LAYOUT DATA TYPE
|
|
1250
|
+
// =============================================================================
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Data returned by the root layout load function.
|
|
1254
|
+
*/
|
|
940
1255
|
export interface LayoutData {
|
|
1256
|
+
/** Active Supabase session, or \`null\` when offline / unauthenticated. */
|
|
941
1257
|
session: Session | null;
|
|
1258
|
+
/** Current authentication mode (\`'online'\`, \`'offline'\`, or \`'none'\`). */
|
|
942
1259
|
authMode: AuthMode;
|
|
1260
|
+
/** Cached offline credentials (display name, avatar) when auth is offline. */
|
|
943
1261
|
offlineProfile: OfflineCredentials | null;
|
|
1262
|
+
/** Whether the single-user account has completed initial setup. */
|
|
944
1263
|
singleUserSetUp?: boolean;
|
|
945
1264
|
}
|
|
946
1265
|
|
|
1266
|
+
// =============================================================================
|
|
1267
|
+
// LOAD FUNCTION
|
|
1268
|
+
// =============================================================================
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* Root layout load — initialises config, resolves auth, and starts sync.
|
|
1272
|
+
*
|
|
1273
|
+
* @param params - SvelteKit load params (provides the current URL).
|
|
1274
|
+
* @returns Layout data with session and auth state.
|
|
1275
|
+
*/
|
|
947
1276
|
export const load: LayoutLoad = async ({ url }): Promise<LayoutData> => {
|
|
948
1277
|
if (browser) {
|
|
1278
|
+
/* Fetch runtime config from /api/config (cached after first call) */
|
|
949
1279
|
const config = await initConfig();
|
|
950
1280
|
if (!config && url.pathname !== '/setup') {
|
|
951
1281
|
redirect(307, '/setup');
|
|
@@ -953,71 +1283,456 @@ export const load: LayoutLoad = async ({ url }): Promise<LayoutData> => {
|
|
|
953
1283
|
if (!config) {
|
|
954
1284
|
return { session: null, authMode: 'none', offlineProfile: null, singleUserSetUp: false };
|
|
955
1285
|
}
|
|
1286
|
+
/* Determine whether the user is online-authenticated, offline, or none */
|
|
956
1287
|
const result = await resolveAuthState();
|
|
957
1288
|
if (result.authMode !== 'none') {
|
|
1289
|
+
/* Kick off background sync (Supabase realtime + IndexedDB) */
|
|
958
1290
|
await startSyncEngine();
|
|
959
1291
|
}
|
|
960
1292
|
return result;
|
|
961
1293
|
}
|
|
1294
|
+
/* SSR fallback — no auth info available on the server */
|
|
962
1295
|
return { session: null, authMode: 'none', offlineProfile: null, singleUserSetUp: false };
|
|
963
1296
|
};
|
|
964
1297
|
`;
|
|
965
1298
|
}
|
|
966
|
-
|
|
967
|
-
|
|
1299
|
+
/**
|
|
1300
|
+
* Generate the root `+layout.svelte` with auth state hydration and TODO stubs.
|
|
1301
|
+
*
|
|
1302
|
+
* @returns The Svelte component source for `src/routes/+layout.svelte`.
|
|
1303
|
+
*/
|
|
1304
|
+
function generateRootLayoutSvelte(opts) {
|
|
1305
|
+
return `<!--
|
|
1306
|
+
@fileoverview Root layout component — app shell, auth hydration,
|
|
1307
|
+
navigation chrome, overlays, and PWA lifecycle.
|
|
1308
|
+
|
|
1309
|
+
This is the outermost Svelte component. It wraps every page and is
|
|
1310
|
+
responsible for hydrating auth state from the load function, rendering
|
|
1311
|
+
the navigation bar / tab bar, and mounting global overlays like the
|
|
1312
|
+
service-worker update prompt.
|
|
1313
|
+
-->
|
|
1314
|
+
<script lang="ts">
|
|
1315
|
+
/**
|
|
1316
|
+
* @fileoverview Root layout script — auth state management, navigation logic,
|
|
1317
|
+
* service worker communication, and global event handlers.
|
|
1318
|
+
*/
|
|
1319
|
+
|
|
1320
|
+
// =============================================================================
|
|
1321
|
+
// Imports
|
|
1322
|
+
// =============================================================================
|
|
1323
|
+
|
|
1324
|
+
/* ── Svelte Lifecycle & Transitions ── */
|
|
1325
|
+
import { onMount, onDestroy } from 'svelte';
|
|
1326
|
+
|
|
1327
|
+
/* ── SvelteKit Utilities ── */
|
|
1328
|
+
import { page } from '$app/stores';
|
|
1329
|
+
import { browser } from '$app/environment';
|
|
1330
|
+
|
|
1331
|
+
/* ── Stellar Engine — Auth & Stores ── */
|
|
1332
|
+
import { lockSingleUser } from '@prabhask5/stellar-engine/auth';
|
|
1333
|
+
import { debug } from '@prabhask5/stellar-engine/utils';
|
|
968
1334
|
import { hydrateAuthState } from '@prabhask5/stellar-engine/kit';
|
|
969
|
-
|
|
1335
|
+
|
|
1336
|
+
/* ── Types ── */
|
|
970
1337
|
import type { LayoutData } from './+layout';
|
|
971
1338
|
|
|
1339
|
+
// =============================================================================
|
|
1340
|
+
// Props
|
|
1341
|
+
// =============================================================================
|
|
1342
|
+
|
|
972
1343
|
interface Props {
|
|
1344
|
+
/** Default slot content — the matched page component. */
|
|
973
1345
|
children?: import('svelte').Snippet;
|
|
1346
|
+
|
|
1347
|
+
/** Layout data from \`+layout.ts\` — session, auth mode, offline profile. */
|
|
974
1348
|
data: LayoutData;
|
|
975
1349
|
}
|
|
976
1350
|
|
|
977
1351
|
let { children, data }: Props = $props();
|
|
978
1352
|
|
|
1353
|
+
// =============================================================================
|
|
1354
|
+
// Component State
|
|
1355
|
+
// =============================================================================
|
|
1356
|
+
|
|
1357
|
+
/* ── Toast Notification ── */
|
|
1358
|
+
/** Whether the toast notification is currently visible. */
|
|
1359
|
+
let showToast = $state(false);
|
|
1360
|
+
|
|
1361
|
+
/** The text content of the current toast notification. */
|
|
1362
|
+
let toastMessage = $state('');
|
|
1363
|
+
|
|
1364
|
+
/** The visual style of the toast — \`'info'\` (purple) or \`'error'\` (pink). */
|
|
1365
|
+
let toastType = $state<'info' | 'error'>('info');
|
|
1366
|
+
|
|
1367
|
+
/* ── Sign-Out ── */
|
|
1368
|
+
/** When \`true\`, a full-screen overlay is shown to mask the sign-out transition. */
|
|
1369
|
+
let isSigningOut = $state(false);
|
|
1370
|
+
|
|
1371
|
+
/* ── Cleanup References ── */
|
|
1372
|
+
/** Stored reference to the chunk error handler so we can remove it on destroy. */
|
|
1373
|
+
let chunkErrorHandler: ((event: PromiseRejectionEvent) => void) | null = null;
|
|
1374
|
+
|
|
1375
|
+
// =============================================================================
|
|
1376
|
+
// Reactive Effects
|
|
1377
|
+
// =============================================================================
|
|
1378
|
+
|
|
1379
|
+
/**
|
|
1380
|
+
* Effect: hydrate the global \`authState\` store from layout load data.
|
|
1381
|
+
*
|
|
1382
|
+
* Runs whenever \`data\` changes (e.g. after navigation or revalidation).
|
|
1383
|
+
* Maps the three possible auth modes to the corresponding store setter:
|
|
1384
|
+
* - \`'supabase'\` + session → \`setSupabaseAuth\`
|
|
1385
|
+
* - \`'offline'\` + cached profile → \`setOfflineAuth\`
|
|
1386
|
+
* - anything else → \`setNoAuth\`
|
|
1387
|
+
*/
|
|
979
1388
|
$effect(() => {
|
|
980
1389
|
hydrateAuthState(data);
|
|
981
1390
|
});
|
|
982
1391
|
|
|
983
|
-
//
|
|
984
|
-
//
|
|
985
|
-
//
|
|
1392
|
+
// =============================================================================
|
|
1393
|
+
// Lifecycle — Mount
|
|
1394
|
+
// =============================================================================
|
|
1395
|
+
|
|
1396
|
+
onMount(() => {
|
|
1397
|
+
// ── Chunk Error Handler ────────────────────────────────────────────────
|
|
1398
|
+
// When navigating offline to a page whose JS chunks aren't cached,
|
|
1399
|
+
// the dynamic import fails and shows a cryptic error. Catch and show a friendly message.
|
|
1400
|
+
chunkErrorHandler = (event: PromiseRejectionEvent) => {
|
|
1401
|
+
const error = event.reason;
|
|
1402
|
+
// Check if this is a chunk loading error (fetch failed or syntax error from 503 response)
|
|
1403
|
+
const isChunkError =
|
|
1404
|
+
error?.message?.includes('Failed to fetch dynamically imported module') ||
|
|
1405
|
+
error?.message?.includes('error loading dynamically imported module') ||
|
|
1406
|
+
error?.message?.includes('Importing a module script failed') ||
|
|
1407
|
+
error?.name === 'ChunkLoadError' ||
|
|
1408
|
+
(error?.message?.includes('Loading chunk') && error?.message?.includes('failed'));
|
|
1409
|
+
|
|
1410
|
+
if (isChunkError && !navigator.onLine) {
|
|
1411
|
+
event.preventDefault(); // Prevent default error handling
|
|
1412
|
+
// Show offline navigation toast
|
|
1413
|
+
toastMessage = "This page isn't available offline. Please reconnect or go back.";
|
|
1414
|
+
toastType = 'info';
|
|
1415
|
+
showToast = true;
|
|
1416
|
+
setTimeout(() => {
|
|
1417
|
+
showToast = false;
|
|
1418
|
+
}, 5000);
|
|
1419
|
+
}
|
|
1420
|
+
};
|
|
1421
|
+
|
|
1422
|
+
window.addEventListener('unhandledrejection', chunkErrorHandler);
|
|
1423
|
+
|
|
1424
|
+
// ── Sign-Out Event Listener ───────────────────────────────────────────
|
|
1425
|
+
// Listen for sign out requests from child pages (e.g. mobile profile page)
|
|
1426
|
+
window.addEventListener('${opts.prefix}:signout', handleSignOut);
|
|
1427
|
+
|
|
1428
|
+
// ── Service Worker — Background Precaching ────────────────────────────
|
|
1429
|
+
// Proactively cache all app chunks for full offline support.
|
|
1430
|
+
// This runs in the background after page load, so it doesn't affect Lighthouse scores.
|
|
1431
|
+
if ('serviceWorker' in navigator) {
|
|
1432
|
+
// Listen for precache completion messages from service worker
|
|
1433
|
+
navigator.serviceWorker.addEventListener('message', (event) => {
|
|
1434
|
+
if (event.data?.type === 'PRECACHE_COMPLETE') {
|
|
1435
|
+
const { cached, total } = event.data;
|
|
1436
|
+
debug('log', \`[PWA] Background precaching complete: \${cached}/\${total} assets cached\`);
|
|
1437
|
+
if (cached === total) {
|
|
1438
|
+
debug('log', '[PWA] Full offline support ready - all pages accessible offline');
|
|
1439
|
+
} else {
|
|
1440
|
+
debug('warn', \`[PWA] Some assets failed to cache: \${total - cached} missing\`);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
// Wait for service worker to be ready (handles first load case)
|
|
1446
|
+
navigator.serviceWorker.ready.then((registration) => {
|
|
1447
|
+
debug('log', '[PWA] Service worker ready, scheduling background precache...');
|
|
1448
|
+
|
|
1449
|
+
// Give the page time to fully load, then trigger background precaching
|
|
1450
|
+
setTimeout(() => {
|
|
1451
|
+
const controller = navigator.serviceWorker.controller || registration.active;
|
|
1452
|
+
if (!controller) {
|
|
1453
|
+
debug('warn', '[PWA] No service worker controller available');
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// First, cache current page's assets (scripts + stylesheets)
|
|
1458
|
+
const scripts = Array.from(document.querySelectorAll('script[src]'))
|
|
1459
|
+
.map((el) => (el as HTMLScriptElement).src)
|
|
1460
|
+
.filter((src) => src.startsWith(location.origin));
|
|
1461
|
+
|
|
1462
|
+
const styles = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
|
|
1463
|
+
.map((el) => (el as HTMLLinkElement).href)
|
|
1464
|
+
.filter((href) => href.startsWith(location.origin));
|
|
1465
|
+
|
|
1466
|
+
const urls = [...scripts, ...styles];
|
|
1467
|
+
|
|
1468
|
+
if (urls.length > 0) {
|
|
1469
|
+
debug('log', \`[PWA] Caching \${urls.length} current page assets...\`);
|
|
1470
|
+
controller.postMessage({
|
|
1471
|
+
type: 'CACHE_URLS',
|
|
1472
|
+
urls
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// Then trigger full background precaching for all app chunks.
|
|
1477
|
+
// This ensures offline support for all pages, not just visited ones.
|
|
1478
|
+
debug('log', '[PWA] Triggering background precache of all app chunks...');
|
|
1479
|
+
controller.postMessage({
|
|
1480
|
+
type: 'PRECACHE_ALL'
|
|
1481
|
+
});
|
|
1482
|
+
}, 500); // Cache assets quickly to reduce window for uncached refreshes
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
// =============================================================================
|
|
1488
|
+
// Lifecycle — Destroy
|
|
1489
|
+
// =============================================================================
|
|
1490
|
+
|
|
1491
|
+
onDestroy(() => {
|
|
1492
|
+
if (browser) {
|
|
1493
|
+
// Cleanup chunk error handler
|
|
1494
|
+
if (chunkErrorHandler) {
|
|
1495
|
+
window.removeEventListener('unhandledrejection', chunkErrorHandler);
|
|
1496
|
+
}
|
|
1497
|
+
// Cleanup sign out listener
|
|
1498
|
+
window.removeEventListener('${opts.prefix}:signout', handleSignOut);
|
|
1499
|
+
}
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
// =============================================================================
|
|
1503
|
+
// Event Handlers
|
|
1504
|
+
// =============================================================================
|
|
1505
|
+
|
|
1506
|
+
/**
|
|
1507
|
+
* Handles the sign-out flow with a visual transition.
|
|
1508
|
+
*
|
|
1509
|
+
* 1. Shows a full-screen "Locking..." overlay immediately.
|
|
1510
|
+
* 2. Waits 250ms for the overlay fade-in to complete.
|
|
1511
|
+
* 3. Calls \`lockSingleUser()\` to stop the engine and clear the session
|
|
1512
|
+
* (but NOT destroy user data).
|
|
1513
|
+
* 4. Hard-navigates to \`/login\` (full page reload to reset all state).
|
|
1514
|
+
*/
|
|
1515
|
+
async function handleSignOut() {
|
|
1516
|
+
// Show full-screen overlay immediately
|
|
1517
|
+
isSigningOut = true;
|
|
1518
|
+
|
|
1519
|
+
// Wait for overlay to fully appear
|
|
1520
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
1521
|
+
|
|
1522
|
+
// Lock the single-user session (stops engine, resets auth state, does NOT destroy data)
|
|
1523
|
+
await lockSingleUser();
|
|
1524
|
+
|
|
1525
|
+
// Navigate to login
|
|
1526
|
+
window.location.href = '/login';
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/**
|
|
1530
|
+
* Checks whether a given route \`href\` matches the current page path.
|
|
1531
|
+
* Used to highlight the active nav item.
|
|
1532
|
+
*
|
|
1533
|
+
* @param href - The route path to check (e.g. \`'/agenda'\`)
|
|
1534
|
+
* @returns \`true\` if the current path starts with \`href\`
|
|
1535
|
+
*/
|
|
1536
|
+
function isActive(href: string): boolean {
|
|
1537
|
+
return $page.url.pathname.startsWith(href);
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/**
|
|
1541
|
+
* Dismisses the currently visible toast notification.
|
|
1542
|
+
*/
|
|
1543
|
+
function dismissToast() {
|
|
1544
|
+
showToast = false;
|
|
1545
|
+
}
|
|
986
1546
|
</script>
|
|
987
1547
|
|
|
988
1548
|
<!-- TODO: Add your app shell template (navbar, tab bar, page transitions, etc.) -->
|
|
989
1549
|
{@render children?.()}
|
|
990
1550
|
`;
|
|
991
1551
|
}
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1552
|
+
// ---------------------------------------------------------------------------
|
|
1553
|
+
// PAGE GENERATORS
|
|
1554
|
+
// ---------------------------------------------------------------------------
|
|
1555
|
+
/**
|
|
1556
|
+
* Generate a minimal home page component with TODO stubs.
|
|
1557
|
+
*
|
|
1558
|
+
* @returns The Svelte component source for `src/routes/+page.svelte`.
|
|
1559
|
+
*/
|
|
1560
|
+
function generateHomePage(opts) {
|
|
1561
|
+
return `<!--
|
|
1562
|
+
@fileoverview Home / landing page — welcome screen and primary content.
|
|
1563
|
+
|
|
1564
|
+
This is the default route (\`/\`). It renders the main content area
|
|
1565
|
+
the user sees after authentication.
|
|
1566
|
+
-->
|
|
1567
|
+
<script lang="ts">
|
|
1568
|
+
/**
|
|
1569
|
+
* @fileoverview Home page script — data access and component state.
|
|
1570
|
+
*/
|
|
1571
|
+
|
|
1572
|
+
// ==========================================================================
|
|
1573
|
+
// IMPORTS
|
|
1574
|
+
// ==========================================================================
|
|
1575
|
+
|
|
1576
|
+
/* ── Stellar Engine — Auth & Stores ── */
|
|
1577
|
+
import { resolveFirstName } from '@prabhask5/stellar-engine/auth';
|
|
995
1578
|
import { onSyncComplete, authState } from '@prabhask5/stellar-engine/stores';
|
|
996
1579
|
|
|
1580
|
+
// ==========================================================================
|
|
1581
|
+
// COMPONENT STATE
|
|
1582
|
+
// ==========================================================================
|
|
1583
|
+
|
|
1584
|
+
/**
|
|
1585
|
+
* Derive the user's first name for the greeting display.
|
|
1586
|
+
* Falls back through session profile → email username → offline profile → 'Explorer'.
|
|
1587
|
+
*/
|
|
1588
|
+
const firstName = $derived(
|
|
1589
|
+
resolveFirstName($authState.session, $authState.offlineProfile)
|
|
1590
|
+
);
|
|
1591
|
+
|
|
1592
|
+
// =============================================================================
|
|
1593
|
+
// Reactive Effects
|
|
1594
|
+
// =============================================================================
|
|
1595
|
+
|
|
1596
|
+
/**
|
|
1597
|
+
* Effect: auth redirect guard.
|
|
1598
|
+
*
|
|
1599
|
+
* Once the auth store finishes loading and resolves to \`'none'\` (no session),
|
|
1600
|
+
* redirect to \`/login\` with a \`redirect\` query param so the login page knows
|
|
1601
|
+
* this was an automatic redirect rather than direct navigation.
|
|
1602
|
+
*/
|
|
1603
|
+
$effect(() => {
|
|
1604
|
+
if (!$authState.isLoading && $authState.mode === 'none') {
|
|
1605
|
+
// Include redirect param so login page knows this was a redirect, not direct navigation
|
|
1606
|
+
goto('/login?redirect=%2F', { replaceState: true });
|
|
1607
|
+
}
|
|
1608
|
+
});
|
|
1609
|
+
|
|
997
1610
|
// TODO: Add home page state and logic
|
|
998
1611
|
</script>
|
|
999
1612
|
|
|
1613
|
+
<svelte:head>
|
|
1614
|
+
<title>Home - ${opts.name}</title>
|
|
1615
|
+
</svelte:head>
|
|
1616
|
+
|
|
1000
1617
|
<!-- TODO: Add home page template -->
|
|
1001
1618
|
`;
|
|
1002
1619
|
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1620
|
+
/**
|
|
1621
|
+
* Generate a minimal error page component.
|
|
1622
|
+
*
|
|
1623
|
+
* @returns The Svelte component source for `src/routes/+error.svelte`.
|
|
1624
|
+
*/
|
|
1625
|
+
function generateErrorPage(opts) {
|
|
1626
|
+
return `<!--
|
|
1627
|
+
@fileoverview Error boundary — handles three scenarios:
|
|
1628
|
+
1. **Offline** — device has no connectivity, show a friendly offline message
|
|
1629
|
+
2. **404** — page not found, offer navigation back to home
|
|
1630
|
+
3. **Generic** — unexpected error, display status code and retry option
|
|
1631
|
+
-->
|
|
1632
|
+
<script lang="ts">
|
|
1633
|
+
/**
|
|
1634
|
+
* @fileoverview Error page script — status detection and recovery actions.
|
|
1635
|
+
*/
|
|
1636
|
+
|
|
1637
|
+
// ==========================================================================
|
|
1638
|
+
// IMPORTS
|
|
1639
|
+
// ==========================================================================
|
|
1640
|
+
|
|
1005
1641
|
import { page } from '$app/stores';
|
|
1006
1642
|
|
|
1007
|
-
//
|
|
1643
|
+
// ==========================================================================
|
|
1644
|
+
// STATE
|
|
1645
|
+
// ==========================================================================
|
|
1646
|
+
|
|
1647
|
+
/** Whether the user is currently offline — drives which error variant is shown. */
|
|
1648
|
+
let isOffline = $state(false);
|
|
1649
|
+
|
|
1650
|
+
// ==========================================================================
|
|
1651
|
+
// REACTIVE EFFECTS
|
|
1652
|
+
// ==========================================================================
|
|
1653
|
+
|
|
1654
|
+
/**
|
|
1655
|
+
* Effect: tracks the browser's online/offline status in real time.
|
|
1656
|
+
* Sets \`isOffline\` on mount and attaches \`online\` / \`offline\` event listeners.
|
|
1657
|
+
* Returns a cleanup function that removes the listeners on destroy.
|
|
1658
|
+
*/
|
|
1659
|
+
$effect(() => {
|
|
1660
|
+
if (browser) {
|
|
1661
|
+
isOffline = !navigator.onLine;
|
|
1662
|
+
|
|
1663
|
+
const handleOnline = () => {
|
|
1664
|
+
isOffline = false;
|
|
1665
|
+
};
|
|
1666
|
+
const handleOffline = () => {
|
|
1667
|
+
isOffline = true;
|
|
1668
|
+
};
|
|
1669
|
+
|
|
1670
|
+
window.addEventListener('online', handleOnline);
|
|
1671
|
+
window.addEventListener('offline', handleOffline);
|
|
1672
|
+
|
|
1673
|
+
return () => {
|
|
1674
|
+
window.removeEventListener('online', handleOnline);
|
|
1675
|
+
window.removeEventListener('offline', handleOffline);
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
});
|
|
1679
|
+
|
|
1680
|
+
// ==========================================================================
|
|
1681
|
+
// EVENT HANDLERS
|
|
1682
|
+
// ==========================================================================
|
|
1683
|
+
|
|
1684
|
+
/**
|
|
1685
|
+
* Reload the current page — useful when the user regains connectivity or
|
|
1686
|
+
* wants to retry after a transient server error.
|
|
1687
|
+
*/
|
|
1688
|
+
function handleRetry() {
|
|
1689
|
+
window.location.reload();
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
/**
|
|
1693
|
+
* Navigate back to the home page via SvelteKit client-side routing.
|
|
1694
|
+
*/
|
|
1695
|
+
function handleGoHome() {
|
|
1696
|
+
goto('/');
|
|
1697
|
+
}
|
|
1008
1698
|
</script>
|
|
1009
1699
|
|
|
1700
|
+
<svelte:head>
|
|
1701
|
+
<title>Error - ${opts.name}</title>
|
|
1702
|
+
</svelte:head>
|
|
1703
|
+
|
|
1010
1704
|
<!-- TODO: Add error page template (status code display, retry button, go home button) -->
|
|
1011
1705
|
`;
|
|
1012
1706
|
}
|
|
1707
|
+
/**
|
|
1708
|
+
* Generate the setup page load function with first-setup / auth guard.
|
|
1709
|
+
*
|
|
1710
|
+
* @returns The TypeScript source for `src/routes/setup/+page.ts`.
|
|
1711
|
+
*/
|
|
1013
1712
|
function generateSetupPageTs() {
|
|
1014
|
-
return
|
|
1713
|
+
return `/**
|
|
1714
|
+
* @fileoverview Setup page access control gate.
|
|
1715
|
+
*
|
|
1716
|
+
* Two modes:
|
|
1717
|
+
* - **Unconfigured** — no runtime config exists yet; anyone can access the
|
|
1718
|
+
* setup wizard to perform first-time Supabase configuration.
|
|
1719
|
+
* - **Configured** — config already saved; only authenticated users may
|
|
1720
|
+
* revisit the setup page to update credentials or redeploy.
|
|
1721
|
+
*/
|
|
1722
|
+
|
|
1723
|
+
import { browser } from '$app/environment';
|
|
1015
1724
|
import { redirect } from '@sveltejs/kit';
|
|
1016
1725
|
import { getConfig } from '@prabhask5/stellar-engine/config';
|
|
1017
|
-
import { getValidSession
|
|
1726
|
+
import { getValidSession } from '@prabhask5/stellar-engine/auth';
|
|
1018
1727
|
import type { PageLoad } from './$types';
|
|
1019
1728
|
|
|
1729
|
+
/**
|
|
1730
|
+
* Guard the setup route — allow first-time setup or authenticated access.
|
|
1731
|
+
*
|
|
1732
|
+
* @returns Page data with an \`isFirstSetup\` flag.
|
|
1733
|
+
*/
|
|
1020
1734
|
export const load: PageLoad = async () => {
|
|
1735
|
+
/* Config and session helpers rely on browser APIs */
|
|
1021
1736
|
if (!browser) return {};
|
|
1022
1737
|
if (!getConfig()) {
|
|
1023
1738
|
return { isFirstSetup: true };
|
|
@@ -1026,35 +1741,280 @@ export const load: PageLoad = async () => {
|
|
|
1026
1741
|
if (!session?.user) {
|
|
1027
1742
|
redirect(307, '/login');
|
|
1028
1743
|
}
|
|
1029
|
-
if (!isAdmin(session.user)) {
|
|
1030
|
-
redirect(307, '/');
|
|
1031
|
-
}
|
|
1032
1744
|
return { isFirstSetup: false };
|
|
1033
1745
|
};
|
|
1034
1746
|
`;
|
|
1035
1747
|
}
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1748
|
+
/**
|
|
1749
|
+
* Generate the setup wizard page component with TODO stubs.
|
|
1750
|
+
*
|
|
1751
|
+
* @returns The Svelte component source for `src/routes/setup/+page.svelte`.
|
|
1752
|
+
*/
|
|
1753
|
+
function generateSetupPageSvelte(opts) {
|
|
1754
|
+
return `<!--
|
|
1755
|
+
@fileoverview Five-step Supabase configuration wizard.
|
|
1756
|
+
|
|
1757
|
+
Guides the user through entering Supabase credentials, validating them
|
|
1758
|
+
against the server, optionally deploying environment variables to Vercel,
|
|
1759
|
+
and reloading the app with the new config active.
|
|
1760
|
+
-->
|
|
1761
|
+
<script lang="ts">
|
|
1762
|
+
/**
|
|
1763
|
+
* @fileoverview Setup wizard page — first-time Supabase configuration.
|
|
1764
|
+
*
|
|
1765
|
+
* Guides the user through a five-step process to connect their own
|
|
1766
|
+
* Supabase backend to Stellar:
|
|
1767
|
+
*
|
|
1768
|
+
* 1. Create a Supabase project (instructions only).
|
|
1769
|
+
* 2. Configure authentication (enable anonymous sign-ins).
|
|
1770
|
+
* 3. Initialize the database by running the schema SQL.
|
|
1771
|
+
* 4. Enter and validate Supabase credentials (URL + anon key).
|
|
1772
|
+
* 5. Persist configuration via Vercel API (set env vars + redeploy).
|
|
1773
|
+
*
|
|
1774
|
+
* After a successful deploy the page polls for a new service-worker
|
|
1775
|
+
* version — once detected the user is prompted to refresh.
|
|
1776
|
+
*
|
|
1777
|
+
* Access is controlled by the companion \`+page.ts\` load function:
|
|
1778
|
+
* - Unconfigured → anyone can reach this page (\`isFirstSetup: true\`).
|
|
1779
|
+
* - Configured → authenticated users only (\`isFirstSetup: false\`).
|
|
1780
|
+
*/
|
|
1781
|
+
|
|
1782
|
+
import { page } from '$app/stores';
|
|
1783
|
+
import { setConfig } from '@prabhask5/stellar-engine/config';
|
|
1784
|
+
import { isOnline } from '@prabhask5/stellar-engine/stores';
|
|
1785
|
+
import { pollForNewServiceWorker } from '@prabhask5/stellar-engine/kit';
|
|
1786
|
+
|
|
1787
|
+
// =============================================================================
|
|
1788
|
+
// Form State — Supabase + Vercel credentials
|
|
1789
|
+
// =============================================================================
|
|
1790
|
+
|
|
1791
|
+
/** Supabase project URL entered by the user */
|
|
1792
|
+
let supabaseUrl = $state('');
|
|
1793
|
+
|
|
1794
|
+
/** Supabase public anon key entered by the user */
|
|
1795
|
+
let supabaseAnonKey = $state('');
|
|
1796
|
+
|
|
1797
|
+
/** One-time Vercel API token for setting env vars */
|
|
1798
|
+
let vercelToken = $state('');
|
|
1799
|
+
|
|
1800
|
+
// =============================================================================
|
|
1801
|
+
// UI State — Validation & Deployment feedback
|
|
1802
|
+
// =============================================================================
|
|
1803
|
+
|
|
1804
|
+
/** Whether the "Test Connection" request is in-flight */
|
|
1805
|
+
let validating = $state(false);
|
|
1806
|
+
|
|
1807
|
+
/** Whether the deploy/redeploy flow is in-flight */
|
|
1808
|
+
let deploying = $state(false);
|
|
1809
|
+
|
|
1810
|
+
/** Error from credential validation, if any */
|
|
1811
|
+
let validateError = $state<string | null>(null);
|
|
1812
|
+
|
|
1813
|
+
/** \`true\` after credentials have been successfully validated */
|
|
1814
|
+
let validateSuccess = $state(false);
|
|
1815
|
+
|
|
1816
|
+
/** Error from the deployment step, if any */
|
|
1817
|
+
let deployError = $state<string | null>(null);
|
|
1818
|
+
|
|
1819
|
+
/** Current deployment pipeline stage — drives the progress UI */
|
|
1820
|
+
let deployStage = $state<'idle' | 'setting-env' | 'deploying' | 'ready'>('idle');
|
|
1821
|
+
|
|
1822
|
+
/** URL returned by Vercel for the triggered deployment (informational) */
|
|
1823
|
+
let _deploymentUrl = $state('');
|
|
1824
|
+
|
|
1825
|
+
// =============================================================================
|
|
1826
|
+
// Derived State
|
|
1827
|
+
// =============================================================================
|
|
1828
|
+
|
|
1829
|
+
/** Whether this is a first-time setup (public) or reconfiguration */
|
|
1830
|
+
const isFirstSetup = $derived(($page.data as { isFirstSetup?: boolean }).isFirstSetup ?? false);
|
|
1831
|
+
|
|
1832
|
+
/**
|
|
1833
|
+
* Snapshot of the credentials at validation time — used to detect
|
|
1834
|
+
* if the user edits the inputs *after* a successful validation.
|
|
1835
|
+
*/
|
|
1836
|
+
let validatedUrl = $state('');
|
|
1837
|
+
let validatedKey = $state('');
|
|
1838
|
+
|
|
1839
|
+
/**
|
|
1840
|
+
* \`true\` when the user changes credentials after a successful
|
|
1841
|
+
* validation — the "Continue" button should be re-disabled.
|
|
1842
|
+
*/
|
|
1843
|
+
const credentialsChanged = $derived(
|
|
1844
|
+
validateSuccess && (supabaseUrl !== validatedUrl || supabaseAnonKey !== validatedKey)
|
|
1845
|
+
);
|
|
1846
|
+
|
|
1847
|
+
// =============================================================================
|
|
1848
|
+
// Effects
|
|
1849
|
+
// =============================================================================
|
|
1850
|
+
|
|
1851
|
+
/**
|
|
1852
|
+
* Auto-reset validation state when the user modifies credentials
|
|
1853
|
+
* after they were already validated — forces re-validation.
|
|
1854
|
+
*/
|
|
1855
|
+
$effect(() => {
|
|
1856
|
+
if (credentialsChanged) {
|
|
1857
|
+
validateSuccess = false;
|
|
1858
|
+
validateError = null;
|
|
1859
|
+
}
|
|
1860
|
+
});
|
|
1861
|
+
|
|
1862
|
+
// =============================================================================
|
|
1863
|
+
// Validation — "Test Connection"
|
|
1864
|
+
// =============================================================================
|
|
1865
|
+
|
|
1866
|
+
/**
|
|
1867
|
+
* Send the entered Supabase credentials to \`/api/setup/validate\`
|
|
1868
|
+
* and update UI state based on the result. On success, also
|
|
1869
|
+
* cache the config locally via \`setConfig\` so the app is usable
|
|
1870
|
+
* immediately after the deployment finishes.
|
|
1871
|
+
*/
|
|
1872
|
+
async function handleValidate() {
|
|
1873
|
+
validateError = null;
|
|
1874
|
+
validateSuccess = false;
|
|
1875
|
+
validating = true;
|
|
1876
|
+
|
|
1877
|
+
try {
|
|
1878
|
+
const res = await fetch('/api/setup/validate', {
|
|
1879
|
+
method: 'POST',
|
|
1880
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1881
|
+
body: JSON.stringify({ supabaseUrl, supabaseAnonKey })
|
|
1882
|
+
});
|
|
1883
|
+
|
|
1884
|
+
const data = await res.json();
|
|
1885
|
+
|
|
1886
|
+
if (data.valid) {
|
|
1887
|
+
validateSuccess = true;
|
|
1888
|
+
validatedUrl = supabaseUrl;
|
|
1889
|
+
validatedKey = supabaseAnonKey;
|
|
1890
|
+
/* Cache config locally so the app works immediately after deploy */
|
|
1891
|
+
setConfig({
|
|
1892
|
+
supabaseUrl,
|
|
1893
|
+
supabaseAnonKey,
|
|
1894
|
+
configured: true
|
|
1895
|
+
});
|
|
1896
|
+
} else {
|
|
1897
|
+
validateError = data.error || 'Validation failed';
|
|
1898
|
+
}
|
|
1899
|
+
} catch (e) {
|
|
1900
|
+
validateError = e instanceof Error ? e.message : 'Network error';
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
validating = false;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
// =============================================================================
|
|
1907
|
+
// Deployment Polling
|
|
1908
|
+
// =============================================================================
|
|
1909
|
+
|
|
1910
|
+
/**
|
|
1911
|
+
* Poll for a new service-worker version to detect when the Vercel
|
|
1912
|
+
* redeployment has finished. Uses the engine's \`pollForNewServiceWorker\`
|
|
1913
|
+
* helper which checks \`registration.update()\` at regular intervals.
|
|
1914
|
+
*
|
|
1915
|
+
* Resolves a Promise when a new SW is detected in the waiting state.
|
|
1916
|
+
*/
|
|
1917
|
+
function pollForDeployment(): Promise<void> {
|
|
1918
|
+
return new Promise((resolve) => {
|
|
1919
|
+
pollForNewServiceWorker({
|
|
1920
|
+
intervalMs: 3000,
|
|
1921
|
+
maxAttempts: 200,
|
|
1922
|
+
onFound: () => {
|
|
1923
|
+
deployStage = 'ready';
|
|
1924
|
+
resolve();
|
|
1925
|
+
}
|
|
1926
|
+
});
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// =============================================================================
|
|
1931
|
+
// Deployment — Set env vars + trigger Vercel redeploy
|
|
1932
|
+
// =============================================================================
|
|
1933
|
+
|
|
1934
|
+
/**
|
|
1935
|
+
* Send credentials and the Vercel token to \`/api/setup/deploy\`,
|
|
1936
|
+
* which sets the environment variables on the Vercel project and
|
|
1937
|
+
* triggers a fresh deployment. Then poll until the new build is live.
|
|
1938
|
+
*/
|
|
1939
|
+
async function handleDeploy() {
|
|
1940
|
+
deployError = null;
|
|
1941
|
+
deploying = true;
|
|
1942
|
+
deployStage = 'setting-env';
|
|
1943
|
+
|
|
1944
|
+
try {
|
|
1945
|
+
const res = await fetch('/api/setup/deploy', {
|
|
1946
|
+
method: 'POST',
|
|
1947
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1948
|
+
body: JSON.stringify({ supabaseUrl, supabaseAnonKey, vercelToken })
|
|
1949
|
+
});
|
|
1950
|
+
|
|
1951
|
+
const data = await res.json();
|
|
1952
|
+
|
|
1953
|
+
if (data.success) {
|
|
1954
|
+
deployStage = 'deploying';
|
|
1955
|
+
_deploymentUrl = data.deploymentUrl || '';
|
|
1956
|
+
/* Poll for the new SW version → marks \`deployStage = 'ready'\` */
|
|
1957
|
+
await pollForDeployment();
|
|
1958
|
+
} else {
|
|
1959
|
+
deployError = data.error || 'Deployment failed';
|
|
1960
|
+
deployStage = 'idle';
|
|
1961
|
+
}
|
|
1962
|
+
} catch (e) {
|
|
1963
|
+
deployError = e instanceof Error ? e.message : 'Network error';
|
|
1964
|
+
deployStage = 'idle';
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
deploying = false;
|
|
1968
|
+
}
|
|
1043
1969
|
</script>
|
|
1044
1970
|
|
|
1971
|
+
<svelte:head>
|
|
1972
|
+
<title>Setup - ${opts.name}</title>
|
|
1973
|
+
</svelte:head>
|
|
1974
|
+
|
|
1045
1975
|
<!-- TODO: Add setup wizard template (Supabase credentials form, validation, Vercel deployment) -->
|
|
1046
1976
|
`;
|
|
1047
1977
|
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1978
|
+
/**
|
|
1979
|
+
* Generate a minimal privacy policy page component.
|
|
1980
|
+
*
|
|
1981
|
+
* @returns The Svelte component source for `src/routes/policy/+page.svelte`.
|
|
1982
|
+
*/
|
|
1983
|
+
function generatePolicyPage(opts) {
|
|
1984
|
+
return `<!--
|
|
1985
|
+
@fileoverview Privacy policy page.
|
|
1986
|
+
|
|
1987
|
+
Static content page that displays the application's privacy policy.
|
|
1988
|
+
Required by app stores and good practice for any app handling user data.
|
|
1989
|
+
-->
|
|
1990
|
+
<script lang="ts">
|
|
1050
1991
|
// TODO: Add any needed imports
|
|
1051
1992
|
</script>
|
|
1052
1993
|
|
|
1994
|
+
<svelte:head>
|
|
1995
|
+
<title>Privacy Policy - ${opts.name}</title>
|
|
1996
|
+
</svelte:head>
|
|
1997
|
+
|
|
1053
1998
|
<!-- TODO: Add privacy policy page content -->
|
|
1054
1999
|
`;
|
|
1055
2000
|
}
|
|
1056
|
-
|
|
1057
|
-
|
|
2001
|
+
/**
|
|
2002
|
+
* Generate the login page component with single-user auth, device
|
|
2003
|
+
* verification, and PIN input TODO stubs.
|
|
2004
|
+
*
|
|
2005
|
+
* @returns The Svelte component source for `src/routes/login/+page.svelte`.
|
|
2006
|
+
*/
|
|
2007
|
+
function generateLoginPage(opts) {
|
|
2008
|
+
return `<!--
|
|
2009
|
+
@fileoverview Login page — three modes:
|
|
2010
|
+
1. **Setup** — first-time account creation (email + PIN)
|
|
2011
|
+
2. **Unlock** — returning user enters PIN to unlock
|
|
2012
|
+
3. **Link Device** — new device links to an existing account via email verification
|
|
2013
|
+
|
|
2014
|
+
Uses BroadcastChannel (\`auth-channel\`) for cross-tab communication with
|
|
2015
|
+
the /confirm page so email verification results propagate instantly.
|
|
2016
|
+
-->
|
|
2017
|
+
<script lang="ts">
|
|
1058
2018
|
import { onMount, onDestroy } from 'svelte';
|
|
1059
2019
|
import { goto, invalidateAll } from '$app/navigation';
|
|
1060
2020
|
import { page } from '$app/stores';
|
|
@@ -1070,29 +2030,768 @@ function generateLoginPage() {
|
|
|
1070
2030
|
} from '@prabhask5/stellar-engine/auth';
|
|
1071
2031
|
import { sendDeviceVerification } from '@prabhask5/stellar-engine';
|
|
1072
2032
|
|
|
1073
|
-
//
|
|
1074
|
-
//
|
|
2033
|
+
// ==========================================================================
|
|
2034
|
+
// LAYOUT / PAGE DATA
|
|
2035
|
+
// ==========================================================================
|
|
2036
|
+
|
|
2037
|
+
/** Whether the single-user account has already been set up on this device */
|
|
2038
|
+
const singleUserSetUp = $derived($page.data.singleUserSetUp);
|
|
2039
|
+
|
|
2040
|
+
/** Post-login redirect URL extracted from \`?redirect=\` query param */
|
|
2041
|
+
const redirectUrl = $derived($page.url.searchParams.get('redirect') || '/');
|
|
2042
|
+
|
|
2043
|
+
// ==========================================================================
|
|
2044
|
+
// SHARED UI STATE
|
|
2045
|
+
// ==========================================================================
|
|
2046
|
+
|
|
2047
|
+
/** \`true\` while any async auth operation is in-flight */
|
|
2048
|
+
let loading = $state(false);
|
|
2049
|
+
|
|
2050
|
+
/** Current error message shown to the user (null = no error) */
|
|
2051
|
+
let error = $state<string | null>(null);
|
|
2052
|
+
|
|
2053
|
+
/** Triggers the CSS shake animation on the login card */
|
|
2054
|
+
let shaking = $state(false);
|
|
2055
|
+
|
|
2056
|
+
/** Set to \`true\` after the component mounts — enables entrance animation */
|
|
2057
|
+
let mounted = $state(false);
|
|
2058
|
+
|
|
2059
|
+
// =============================================================================
|
|
2060
|
+
// Setup Mode State (step 1 → email/name, step 2 → PIN creation)
|
|
2061
|
+
// =============================================================================
|
|
2062
|
+
|
|
2063
|
+
/** User's email address for account creation */
|
|
2064
|
+
let email = $state('');
|
|
2065
|
+
|
|
2066
|
+
/** User's first name */
|
|
2067
|
+
let firstName = $state('');
|
|
2068
|
+
|
|
2069
|
+
/** User's last name (optional) */
|
|
2070
|
+
let lastName = $state('');
|
|
2071
|
+
|
|
2072
|
+
/** Individual digit values for the 6-digit PIN code */
|
|
2073
|
+
let codeDigits = $state(['', '', '', '', '', '']);
|
|
2074
|
+
|
|
2075
|
+
/** Individual digit values for the PIN confirmation */
|
|
2076
|
+
let confirmDigits = $state(['', '', '', '', '', '']);
|
|
2077
|
+
|
|
2078
|
+
/** Concatenated PIN code — derived from \`codeDigits\` */
|
|
2079
|
+
const code = $derived(codeDigits.join(''));
|
|
2080
|
+
|
|
2081
|
+
/** Concatenated confirmation code — derived from \`confirmDigits\` */
|
|
2082
|
+
const confirmCode = $derived(confirmDigits.join(''));
|
|
2083
|
+
|
|
2084
|
+
/** Current setup wizard step: 1 = email + name, 2 = PIN creation */
|
|
2085
|
+
let setupStep = $state(1); // 1 = email + name, 2 = code
|
|
2086
|
+
|
|
2087
|
+
// =============================================================================
|
|
2088
|
+
// Unlock Mode State (returning user on this device)
|
|
2089
|
+
// =============================================================================
|
|
2090
|
+
|
|
2091
|
+
/** Individual digit values for the unlock PIN */
|
|
2092
|
+
let unlockDigits = $state(['', '', '', '', '', '']);
|
|
2093
|
+
|
|
2094
|
+
/** Concatenated unlock code — derived from \`unlockDigits\` */
|
|
2095
|
+
const unlockCode = $derived(unlockDigits.join(''));
|
|
2096
|
+
|
|
2097
|
+
/** Cached user profile info (first/last name) for the welcome message */
|
|
2098
|
+
let userInfo = $state<{ firstName: string; lastName: string } | null>(null);
|
|
2099
|
+
|
|
2100
|
+
// =============================================================================
|
|
2101
|
+
// Link Device Mode State (new device, existing remote user)
|
|
2102
|
+
// =============================================================================
|
|
2103
|
+
|
|
2104
|
+
/** Individual digit values for the device-linking PIN */
|
|
2105
|
+
let linkDigits = $state(['', '', '', '', '', '']);
|
|
2106
|
+
|
|
2107
|
+
/** Concatenated link code — derived from \`linkDigits\` */
|
|
2108
|
+
const linkCode = $derived(linkDigits.join(''));
|
|
2109
|
+
|
|
2110
|
+
/**
|
|
2111
|
+
* Remote user info fetched from the gate config — contains email,
|
|
2112
|
+
* gate type, code length, and profile data for the welcome message.
|
|
2113
|
+
*/
|
|
2114
|
+
let remoteUser = $state<{
|
|
2115
|
+
email: string;
|
|
2116
|
+
gateType: string;
|
|
2117
|
+
codeLength: number;
|
|
2118
|
+
profile: Record<string, unknown>;
|
|
2119
|
+
} | null>(null);
|
|
2120
|
+
|
|
2121
|
+
/** \`true\` when we detected a remote user and entered link-device mode */
|
|
2122
|
+
let linkMode = $state(false);
|
|
2123
|
+
|
|
2124
|
+
/** Loading state specific to the link-device flow */
|
|
2125
|
+
let linkLoading = $state(false);
|
|
2126
|
+
|
|
2127
|
+
/** \`true\` when offline and no local setup exists — shows offline card */
|
|
2128
|
+
let offlineNoSetup = $state(false);
|
|
2129
|
+
|
|
2130
|
+
// =============================================================================
|
|
2131
|
+
// Rate-Limit Countdown State
|
|
2132
|
+
// =============================================================================
|
|
2133
|
+
|
|
2134
|
+
/** Seconds remaining before the user can retry after a rate-limit */
|
|
2135
|
+
let retryCountdown = $state(0);
|
|
2136
|
+
|
|
2137
|
+
/** Interval handle for the retry countdown timer */
|
|
2138
|
+
let retryTimer: ReturnType<typeof setInterval> | null = null;
|
|
2139
|
+
|
|
2140
|
+
// =============================================================================
|
|
2141
|
+
// Modal State — Email Confirmation & Device Verification
|
|
2142
|
+
// =============================================================================
|
|
2143
|
+
|
|
2144
|
+
/** Show the "check your email" modal after initial signup */
|
|
2145
|
+
let showConfirmationModal = $state(false);
|
|
2146
|
+
|
|
2147
|
+
/** Show the "new device detected" verification modal */
|
|
2148
|
+
let showDeviceVerificationModal = $state(false);
|
|
2149
|
+
|
|
2150
|
+
/** Masked email address displayed in the device-verification modal */
|
|
2151
|
+
let maskedEmail = $state('');
|
|
2152
|
+
|
|
2153
|
+
/** Seconds remaining before the "resend" button re-enables */
|
|
2154
|
+
let resendCooldown = $state(0);
|
|
2155
|
+
|
|
2156
|
+
/** Interval handle for the resend cooldown timer */
|
|
2157
|
+
let resendTimer: ReturnType<typeof setInterval> | null = null;
|
|
2158
|
+
|
|
2159
|
+
/** Interval handle for polling device verification status */
|
|
2160
|
+
let verificationPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
2161
|
+
|
|
2162
|
+
/** Guard flag to prevent double-execution of verification completion */
|
|
2163
|
+
let verificationCompleting = false; // guard against double execution
|
|
2164
|
+
|
|
2165
|
+
// =============================================================================
|
|
2166
|
+
// Input Refs — DOM references for focus management
|
|
2167
|
+
// =============================================================================
|
|
2168
|
+
|
|
2169
|
+
/** References to the 6 setup-code \`<input>\` elements */
|
|
2170
|
+
let codeInputs: HTMLInputElement[] = $state([]);
|
|
2171
|
+
|
|
2172
|
+
/** References to the 6 confirm-code \`<input>\` elements */
|
|
2173
|
+
let confirmInputs: HTMLInputElement[] = $state([]);
|
|
2174
|
+
|
|
2175
|
+
/** References to the 6 unlock-code \`<input>\` elements */
|
|
2176
|
+
let unlockInputs: HTMLInputElement[] = $state([]);
|
|
2177
|
+
|
|
2178
|
+
/** References to the link-code \`<input>\` elements */
|
|
2179
|
+
let linkInputs: HTMLInputElement[] = $state([]);
|
|
2180
|
+
|
|
2181
|
+
// =============================================================================
|
|
2182
|
+
// Cross-Tab Communication
|
|
2183
|
+
// =============================================================================
|
|
2184
|
+
|
|
2185
|
+
/** BroadcastChannel instance for receiving \`AUTH_CONFIRMED\` from \`/confirm\` */
|
|
2186
|
+
let authChannel: BroadcastChannel | null = null;
|
|
2187
|
+
|
|
2188
|
+
// =============================================================================
|
|
2189
|
+
// Lifecycle — onMount
|
|
2190
|
+
// =============================================================================
|
|
2191
|
+
|
|
2192
|
+
onMount(async () => {
|
|
2193
|
+
mounted = true;
|
|
2194
|
+
|
|
2195
|
+
/* ── Existing local account → fetch user info for the welcome card ──── */
|
|
2196
|
+
if (singleUserSetUp) {
|
|
2197
|
+
const info = await getSingleUserInfo();
|
|
2198
|
+
if (info) {
|
|
2199
|
+
userInfo = {
|
|
2200
|
+
firstName: (info.profile.firstName as string) || '',
|
|
2201
|
+
lastName: (info.profile.lastName as string) || ''
|
|
2202
|
+
};
|
|
2203
|
+
}
|
|
2204
|
+
} else {
|
|
2205
|
+
/* ── No local setup → check for a remote user to link to ──── */
|
|
2206
|
+
const isOffline = typeof navigator !== 'undefined' && !navigator.onLine;
|
|
2207
|
+
if (isOffline) {
|
|
2208
|
+
offlineNoSetup = true;
|
|
2209
|
+
} else {
|
|
2210
|
+
try {
|
|
2211
|
+
const remote = await fetchRemoteGateConfig();
|
|
2212
|
+
if (remote) {
|
|
2213
|
+
remoteUser = remote;
|
|
2214
|
+
linkMode = true;
|
|
2215
|
+
}
|
|
2216
|
+
} catch {
|
|
2217
|
+
/* No remote user found — fall through to normal setup */
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
/* ── Listen for auth confirmation from the \`/confirm\` page ──── */
|
|
2223
|
+
try {
|
|
2224
|
+
authChannel = new BroadcastChannel('stellar-auth-channel');
|
|
2225
|
+
authChannel.onmessage = async (event) => {
|
|
2226
|
+
if (event.data?.type === 'AUTH_CONFIRMED') {
|
|
2227
|
+
/* Bring this tab to the foreground before the confirm tab closes */
|
|
2228
|
+
window.focus();
|
|
2229
|
+
if (showConfirmationModal) {
|
|
2230
|
+
/* Setup confirmation complete → finalize account */
|
|
2231
|
+
const result = await completeSingleUserSetup();
|
|
2232
|
+
if (!result.error) {
|
|
2233
|
+
showConfirmationModal = false;
|
|
2234
|
+
await invalidateAll();
|
|
2235
|
+
goto('/');
|
|
2236
|
+
} else {
|
|
2237
|
+
error = result.error;
|
|
2238
|
+
showConfirmationModal = false;
|
|
2239
|
+
}
|
|
2240
|
+
} else if (showDeviceVerificationModal) {
|
|
2241
|
+
/* Device verification complete (same-browser broadcast) */
|
|
2242
|
+
await handleVerificationComplete();
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
};
|
|
2246
|
+
} catch {
|
|
2247
|
+
/* BroadcastChannel not supported — user must manually refresh */
|
|
2248
|
+
}
|
|
2249
|
+
});
|
|
2250
|
+
|
|
2251
|
+
// =============================================================================
|
|
2252
|
+
// Lifecycle — onDestroy (cleanup timers & channels)
|
|
2253
|
+
// =============================================================================
|
|
2254
|
+
|
|
2255
|
+
onDestroy(() => {
|
|
2256
|
+
authChannel?.close();
|
|
2257
|
+
if (resendTimer) clearInterval(resendTimer);
|
|
2258
|
+
if (retryTimer) clearInterval(retryTimer);
|
|
2259
|
+
stopVerificationPolling();
|
|
2260
|
+
});
|
|
2261
|
+
|
|
2262
|
+
// =============================================================================
|
|
2263
|
+
// Device Verification Polling
|
|
2264
|
+
// =============================================================================
|
|
2265
|
+
|
|
2266
|
+
/**
|
|
2267
|
+
* Start polling the engine every 3 seconds to check whether the
|
|
2268
|
+
* device has been trusted (the user clicked the email link on
|
|
2269
|
+
* another device/browser).
|
|
2270
|
+
*/
|
|
2271
|
+
function startVerificationPolling() {
|
|
2272
|
+
stopVerificationPolling();
|
|
2273
|
+
verificationPollTimer = setInterval(async () => {
|
|
2274
|
+
if (verificationCompleting) return;
|
|
2275
|
+
const trusted = await pollDeviceVerification();
|
|
2276
|
+
if (trusted) {
|
|
2277
|
+
await handleVerificationComplete();
|
|
2278
|
+
}
|
|
2279
|
+
}, 3000);
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
/**
|
|
2283
|
+
* Stop the verification polling interval and clear the handle.
|
|
2284
|
+
*/
|
|
2285
|
+
function stopVerificationPolling() {
|
|
2286
|
+
if (verificationPollTimer) {
|
|
2287
|
+
clearInterval(verificationPollTimer);
|
|
2288
|
+
verificationPollTimer = null;
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
/**
|
|
2293
|
+
* Finalize device verification — calls \`completeDeviceVerification\`
|
|
2294
|
+
* and redirects on success. Guarded by \`verificationCompleting\` to
|
|
2295
|
+
* prevent double-execution from both polling and BroadcastChannel.
|
|
2296
|
+
*/
|
|
2297
|
+
async function handleVerificationComplete() {
|
|
2298
|
+
if (verificationCompleting) return;
|
|
2299
|
+
verificationCompleting = true;
|
|
2300
|
+
stopVerificationPolling();
|
|
2301
|
+
|
|
2302
|
+
const result = await completeDeviceVerification();
|
|
2303
|
+
if (!result.error) {
|
|
2304
|
+
showDeviceVerificationModal = false;
|
|
2305
|
+
await invalidateAll();
|
|
2306
|
+
goto(redirectUrl);
|
|
2307
|
+
} else {
|
|
2308
|
+
error = result.error;
|
|
2309
|
+
showDeviceVerificationModal = false;
|
|
2310
|
+
verificationCompleting = false;
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
// =============================================================================
|
|
2315
|
+
// Resend & Retry Cooldowns
|
|
2316
|
+
// =============================================================================
|
|
2317
|
+
|
|
2318
|
+
/**
|
|
2319
|
+
* Start a 30-second cooldown on the "Resend email" button to
|
|
2320
|
+
* prevent spamming the email service.
|
|
2321
|
+
*/
|
|
2322
|
+
function startResendCooldown() {
|
|
2323
|
+
resendCooldown = 30;
|
|
2324
|
+
if (resendTimer) clearInterval(resendTimer);
|
|
2325
|
+
resendTimer = setInterval(() => {
|
|
2326
|
+
resendCooldown--;
|
|
2327
|
+
if (resendCooldown <= 0 && resendTimer) {
|
|
2328
|
+
clearInterval(resendTimer);
|
|
2329
|
+
resendTimer = null;
|
|
2330
|
+
}
|
|
2331
|
+
}, 1000);
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
/**
|
|
2335
|
+
* Start a countdown after receiving a rate-limit response from the
|
|
2336
|
+
* server. Disables the code inputs and auto-clears the error when
|
|
2337
|
+
* the countdown reaches zero.
|
|
2338
|
+
*
|
|
2339
|
+
* @param ms - The \`retryAfterMs\` value from the server response
|
|
2340
|
+
*/
|
|
2341
|
+
function startRetryCountdown(ms: number) {
|
|
2342
|
+
retryCountdown = Math.ceil(ms / 1000);
|
|
2343
|
+
if (retryTimer) clearInterval(retryTimer);
|
|
2344
|
+
retryTimer = setInterval(() => {
|
|
2345
|
+
retryCountdown--;
|
|
2346
|
+
if (retryCountdown <= 0) {
|
|
2347
|
+
retryCountdown = 0;
|
|
2348
|
+
error = null;
|
|
2349
|
+
if (retryTimer) {
|
|
2350
|
+
clearInterval(retryTimer);
|
|
2351
|
+
retryTimer = null;
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
}, 1000);
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
// =============================================================================
|
|
2358
|
+
// Email Resend Handler
|
|
2359
|
+
// =============================================================================
|
|
2360
|
+
|
|
2361
|
+
/**
|
|
2362
|
+
* Resend the confirmation or verification email depending on
|
|
2363
|
+
* which modal is currently visible. Respects the resend cooldown.
|
|
2364
|
+
*/
|
|
2365
|
+
async function handleResendEmail() {
|
|
2366
|
+
if (resendCooldown > 0) return;
|
|
2367
|
+
startResendCooldown();
|
|
2368
|
+
/* For setup confirmation → resend the signup email */
|
|
2369
|
+
if (showConfirmationModal) {
|
|
2370
|
+
const { resendConfirmationEmail } = await import('@prabhask5/stellar-engine');
|
|
2371
|
+
await resendConfirmationEmail(email);
|
|
2372
|
+
}
|
|
2373
|
+
/* For device verification → resend the OTP email */
|
|
2374
|
+
if (showDeviceVerificationModal) {
|
|
2375
|
+
const info = await getSingleUserInfo();
|
|
2376
|
+
if (info?.email) {
|
|
2377
|
+
await sendDeviceVerification(info.email);
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
// =============================================================================
|
|
2383
|
+
// Digit Input Handlers — Shared across all PIN-code fields
|
|
2384
|
+
// =============================================================================
|
|
2385
|
+
|
|
2386
|
+
/**
|
|
2387
|
+
* Handle a single digit being typed into a PIN input box. Filters
|
|
2388
|
+
* non-numeric characters, auto-advances focus, and triggers
|
|
2389
|
+
* \`onComplete\` when the last digit is filled.
|
|
2390
|
+
*
|
|
2391
|
+
* @param digits - The reactive digit array being edited
|
|
2392
|
+
* @param index - Which position in the array this input represents
|
|
2393
|
+
* @param event - The native \`input\` DOM event
|
|
2394
|
+
* @param inputs - Array of \`HTMLInputElement\` refs for focus management
|
|
2395
|
+
* @param onComplete - Optional callback invoked when all digits are filled
|
|
2396
|
+
*/
|
|
2397
|
+
function handleDigitInput(
|
|
2398
|
+
digits: string[],
|
|
2399
|
+
index: number,
|
|
2400
|
+
event: Event,
|
|
2401
|
+
inputs: HTMLInputElement[],
|
|
2402
|
+
onComplete?: () => void
|
|
2403
|
+
) {
|
|
2404
|
+
const input = event.target as HTMLInputElement;
|
|
2405
|
+
const value = input.value.replace(/[^0-9]/g, '');
|
|
2406
|
+
|
|
2407
|
+
if (value.length > 0) {
|
|
2408
|
+
digits[index] = value.charAt(value.length - 1);
|
|
2409
|
+
input.value = digits[index];
|
|
2410
|
+
/* Auto-focus the next input box */
|
|
2411
|
+
if (index < digits.length - 1 && inputs[index + 1]) {
|
|
2412
|
+
inputs[index + 1].focus();
|
|
2413
|
+
}
|
|
2414
|
+
/* Auto-submit when the last digit is entered (brief delay for UX) */
|
|
2415
|
+
if (index === digits.length - 1 && onComplete && digits.every((d) => d !== '')) {
|
|
2416
|
+
setTimeout(() => onComplete(), 300);
|
|
2417
|
+
}
|
|
2418
|
+
} else {
|
|
2419
|
+
digits[index] = '';
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
/**
|
|
2424
|
+
* Handle backspace in a PIN input — moves focus to the previous
|
|
2425
|
+
* input when the current one is already empty.
|
|
2426
|
+
*
|
|
2427
|
+
* @param digits - The reactive digit array
|
|
2428
|
+
* @param index - Current position index
|
|
2429
|
+
* @param event - The native \`keydown\` event
|
|
2430
|
+
* @param inputs - Array of \`HTMLInputElement\` refs
|
|
2431
|
+
*/
|
|
2432
|
+
function handleDigitKeydown(
|
|
2433
|
+
digits: string[],
|
|
2434
|
+
index: number,
|
|
2435
|
+
event: KeyboardEvent,
|
|
2436
|
+
inputs: HTMLInputElement[]
|
|
2437
|
+
) {
|
|
2438
|
+
if (event.key === 'Backspace') {
|
|
2439
|
+
if (digits[index] === '' && index > 0 && inputs[index - 1]) {
|
|
2440
|
+
inputs[index - 1].focus();
|
|
2441
|
+
digits[index - 1] = '';
|
|
2442
|
+
} else {
|
|
2443
|
+
digits[index] = '';
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
/**
|
|
2449
|
+
* Handle paste into a PIN input — distributes pasted digits across
|
|
2450
|
+
* all input boxes and auto-submits if the full code was pasted.
|
|
2451
|
+
*
|
|
2452
|
+
* @param digits - The reactive digit array
|
|
2453
|
+
* @param event - The native \`paste\` clipboard event
|
|
2454
|
+
* @param inputs - Array of \`HTMLInputElement\` refs
|
|
2455
|
+
* @param onComplete - Optional callback invoked when all digits are filled
|
|
2456
|
+
*/
|
|
2457
|
+
function handleDigitPaste(
|
|
2458
|
+
digits: string[],
|
|
2459
|
+
event: ClipboardEvent,
|
|
2460
|
+
inputs: HTMLInputElement[],
|
|
2461
|
+
onComplete?: () => void
|
|
2462
|
+
) {
|
|
2463
|
+
event.preventDefault();
|
|
2464
|
+
const pasted = (event.clipboardData?.getData('text') || '').replace(/[^0-9]/g, '');
|
|
2465
|
+
for (let i = 0; i < digits.length && i < pasted.length; i++) {
|
|
2466
|
+
digits[i] = pasted[i];
|
|
2467
|
+
if (inputs[i]) inputs[i].value = pasted[i];
|
|
2468
|
+
}
|
|
2469
|
+
const focusIndex = Math.min(pasted.length, digits.length - 1);
|
|
2470
|
+
if (inputs[focusIndex]) inputs[focusIndex].focus();
|
|
2471
|
+
/* Auto-submit if the full code was pasted at once */
|
|
2472
|
+
if (pasted.length >= digits.length && onComplete && digits.every((d) => d !== '')) {
|
|
2473
|
+
onComplete();
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
// =============================================================================
|
|
2478
|
+
// Setup Mode — Step Navigation
|
|
2479
|
+
// =============================================================================
|
|
2480
|
+
|
|
2481
|
+
/**
|
|
2482
|
+
* Validate email and first name, then advance to the PIN-creation
|
|
2483
|
+
* step (step 2). Shows an error if validation fails.
|
|
2484
|
+
*/
|
|
2485
|
+
function goToCodeStep() {
|
|
2486
|
+
if (!email.trim() || !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email.trim())) {
|
|
2487
|
+
error = 'Please enter a valid email address';
|
|
2488
|
+
return;
|
|
2489
|
+
}
|
|
2490
|
+
if (!firstName.trim()) {
|
|
2491
|
+
error = 'First name is required';
|
|
2492
|
+
return;
|
|
2493
|
+
}
|
|
2494
|
+
error = null;
|
|
2495
|
+
setupStep = 2;
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
/**
|
|
2499
|
+
* Navigate back from step 2 (PIN creation) to step 1 (email/name).
|
|
2500
|
+
*/
|
|
2501
|
+
function goBackToNameStep() {
|
|
2502
|
+
setupStep = 1;
|
|
2503
|
+
error = null;
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
/**
|
|
2507
|
+
* Auto-focus the first confirm-code input when the primary code
|
|
2508
|
+
* is fully entered.
|
|
2509
|
+
*/
|
|
2510
|
+
function autoFocusConfirm() {
|
|
2511
|
+
if (confirmInputs[0]) confirmInputs[0].focus();
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
/**
|
|
2515
|
+
* Trigger setup submission when the confirm-code auto-completes.
|
|
2516
|
+
*/
|
|
2517
|
+
function autoSubmitSetup() {
|
|
2518
|
+
if (confirmDigits.every((d) => d !== '')) {
|
|
2519
|
+
handleSetup();
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
/**
|
|
2524
|
+
* Trigger unlock submission when the unlock-code auto-completes.
|
|
2525
|
+
*/
|
|
2526
|
+
function autoSubmitUnlock() {
|
|
2527
|
+
handleUnlock();
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
// =============================================================================
|
|
2531
|
+
// Setup Mode — Account Creation
|
|
2532
|
+
// =============================================================================
|
|
2533
|
+
|
|
2534
|
+
/**
|
|
2535
|
+
* Handle the full setup flow: validate the code matches its
|
|
2536
|
+
* confirmation, call \`setupSingleUser\`, and handle the response
|
|
2537
|
+
* (which may require email confirmation or succeed immediately).
|
|
2538
|
+
*/
|
|
2539
|
+
async function handleSetup() {
|
|
2540
|
+
if (loading) return;
|
|
2541
|
+
|
|
2542
|
+
error = null;
|
|
2543
|
+
|
|
2544
|
+
if (code.length !== 6) {
|
|
2545
|
+
error = 'Please enter a 6-digit code';
|
|
2546
|
+
return;
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
/* Verify code and confirmation match */
|
|
2550
|
+
if (code !== confirmCode) {
|
|
2551
|
+
error = 'Codes do not match';
|
|
2552
|
+
shaking = true;
|
|
2553
|
+
setTimeout(() => {
|
|
2554
|
+
shaking = false;
|
|
2555
|
+
}, 500);
|
|
2556
|
+
/* Clear confirm digits and refocus the first confirm input */
|
|
2557
|
+
confirmDigits = ['', '', '', '', '', ''];
|
|
2558
|
+
if (confirmInputs[0]) confirmInputs[0].focus();
|
|
2559
|
+
return;
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
loading = true;
|
|
2563
|
+
|
|
2564
|
+
try {
|
|
2565
|
+
const result = await setupSingleUser(
|
|
2566
|
+
code,
|
|
2567
|
+
{
|
|
2568
|
+
firstName: firstName.trim(),
|
|
2569
|
+
lastName: lastName.trim()
|
|
2570
|
+
},
|
|
2571
|
+
email.trim()
|
|
2572
|
+
);
|
|
2573
|
+
if (result.error) {
|
|
2574
|
+
error = result.error;
|
|
2575
|
+
shaking = true;
|
|
2576
|
+
setTimeout(() => {
|
|
2577
|
+
shaking = false;
|
|
2578
|
+
}, 500);
|
|
2579
|
+
codeDigits = ['', '', '', '', '', ''];
|
|
2580
|
+
confirmDigits = ['', '', '', '', '', ''];
|
|
2581
|
+
if (codeInputs[0]) codeInputs[0].focus();
|
|
2582
|
+
return;
|
|
2583
|
+
}
|
|
2584
|
+
if (result.confirmationRequired) {
|
|
2585
|
+
/* Email confirmation needed → show the "check your email" modal */
|
|
2586
|
+
showConfirmationModal = true;
|
|
2587
|
+
startResendCooldown();
|
|
2588
|
+
return;
|
|
2589
|
+
}
|
|
2590
|
+
/* No confirmation needed → go straight to the app */
|
|
2591
|
+
await invalidateAll();
|
|
2592
|
+
goto('/');
|
|
2593
|
+
} catch (err: unknown) {
|
|
2594
|
+
error = err instanceof Error ? err.message : 'Setup failed. Please try again.';
|
|
2595
|
+
shaking = true;
|
|
2596
|
+
setTimeout(() => {
|
|
2597
|
+
shaking = false;
|
|
2598
|
+
}, 500);
|
|
2599
|
+
codeDigits = ['', '', '', '', '', ''];
|
|
2600
|
+
confirmDigits = ['', '', '', '', '', ''];
|
|
2601
|
+
if (codeInputs[0]) codeInputs[0].focus();
|
|
2602
|
+
} finally {
|
|
2603
|
+
loading = false;
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
// =============================================================================
|
|
2608
|
+
// Unlock Mode — PIN Entry for Returning Users
|
|
2609
|
+
// =============================================================================
|
|
2610
|
+
|
|
2611
|
+
/**
|
|
2612
|
+
* Attempt to unlock the local account with the entered 6-digit PIN.
|
|
2613
|
+
* Handles rate-limiting, device verification requirements, and
|
|
2614
|
+
* error feedback with shake animation.
|
|
2615
|
+
*/
|
|
2616
|
+
async function handleUnlock() {
|
|
2617
|
+
if (loading || retryCountdown > 0) return;
|
|
2618
|
+
|
|
2619
|
+
error = null;
|
|
2620
|
+
|
|
2621
|
+
if (unlockCode.length !== 6) {
|
|
2622
|
+
error = 'Please enter your 6-digit code';
|
|
2623
|
+
return;
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
loading = true;
|
|
2627
|
+
|
|
2628
|
+
try {
|
|
2629
|
+
const result = await unlockSingleUser(unlockCode);
|
|
2630
|
+
if (result.error) {
|
|
2631
|
+
error = result.error;
|
|
2632
|
+
if (result.retryAfterMs) {
|
|
2633
|
+
startRetryCountdown(result.retryAfterMs);
|
|
2634
|
+
}
|
|
2635
|
+
shaking = true;
|
|
2636
|
+
setTimeout(() => {
|
|
2637
|
+
shaking = false;
|
|
2638
|
+
}, 500);
|
|
2639
|
+
unlockDigits = ['', '', '', '', '', ''];
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2642
|
+
if (result.deviceVerificationRequired) {
|
|
2643
|
+
/* Untrusted device → show verification modal + start polling */
|
|
2644
|
+
maskedEmail = result.maskedEmail || '';
|
|
2645
|
+
showDeviceVerificationModal = true;
|
|
2646
|
+
startResendCooldown();
|
|
2647
|
+
startVerificationPolling();
|
|
2648
|
+
return;
|
|
2649
|
+
}
|
|
2650
|
+
/* Success → navigate to the redirect target */
|
|
2651
|
+
await invalidateAll();
|
|
2652
|
+
goto(redirectUrl);
|
|
2653
|
+
} catch (err: unknown) {
|
|
2654
|
+
error = err instanceof Error ? err.message : 'Incorrect code';
|
|
2655
|
+
shaking = true;
|
|
2656
|
+
setTimeout(() => {
|
|
2657
|
+
shaking = false;
|
|
2658
|
+
}, 500);
|
|
2659
|
+
unlockDigits = ['', '', '', '', '', ''];
|
|
2660
|
+
} finally {
|
|
2661
|
+
loading = false;
|
|
2662
|
+
if (error) {
|
|
2663
|
+
await tick();
|
|
2664
|
+
if (unlockInputs[0]) unlockInputs[0].focus();
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
// =============================================================================
|
|
2670
|
+
// Link Device Mode — Connect a New Device to an Existing Account
|
|
2671
|
+
// =============================================================================
|
|
2672
|
+
|
|
2673
|
+
/**
|
|
2674
|
+
* Trigger link submission when the link-code auto-completes.
|
|
2675
|
+
*/
|
|
2676
|
+
function autoSubmitLink() {
|
|
2677
|
+
if (linkDigits.every((d) => d !== '')) {
|
|
2678
|
+
handleLink();
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
/**
|
|
2683
|
+
* Attempt to link this device to the remote user account by
|
|
2684
|
+
* submitting the PIN. Similar flow to unlock — may require device
|
|
2685
|
+
* verification or trigger rate-limiting.
|
|
2686
|
+
*/
|
|
2687
|
+
async function handleLink() {
|
|
2688
|
+
if (linkLoading || !remoteUser || retryCountdown > 0) return;
|
|
2689
|
+
|
|
2690
|
+
error = null;
|
|
2691
|
+
|
|
2692
|
+
if (linkCode.length !== remoteUser.codeLength) {
|
|
2693
|
+
error = \`Please enter a \${remoteUser.codeLength}-digit code\`;
|
|
2694
|
+
return;
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
linkLoading = true;
|
|
2698
|
+
try {
|
|
2699
|
+
const result = await linkSingleUserDevice(remoteUser.email, linkCode);
|
|
2700
|
+
if (result.error) {
|
|
2701
|
+
error = result.error;
|
|
2702
|
+
if (result.retryAfterMs) {
|
|
2703
|
+
startRetryCountdown(result.retryAfterMs);
|
|
2704
|
+
}
|
|
2705
|
+
shaking = true;
|
|
2706
|
+
setTimeout(() => {
|
|
2707
|
+
shaking = false;
|
|
2708
|
+
}, 500);
|
|
2709
|
+
linkDigits = Array(remoteUser.codeLength).fill('');
|
|
2710
|
+
return;
|
|
2711
|
+
}
|
|
2712
|
+
if (result.deviceVerificationRequired) {
|
|
2713
|
+
maskedEmail = result.maskedEmail || '';
|
|
2714
|
+
showDeviceVerificationModal = true;
|
|
2715
|
+
startResendCooldown();
|
|
2716
|
+
startVerificationPolling();
|
|
2717
|
+
return;
|
|
2718
|
+
}
|
|
2719
|
+
/* Success → navigate to the redirect target */
|
|
2720
|
+
await invalidateAll();
|
|
2721
|
+
goto(redirectUrl);
|
|
2722
|
+
} catch (err: unknown) {
|
|
2723
|
+
error = err instanceof Error ? err.message : 'Incorrect code';
|
|
2724
|
+
shaking = true;
|
|
2725
|
+
setTimeout(() => {
|
|
2726
|
+
shaking = false;
|
|
2727
|
+
}, 500);
|
|
2728
|
+
linkDigits = Array(remoteUser.codeLength).fill('');
|
|
2729
|
+
} finally {
|
|
2730
|
+
linkLoading = false;
|
|
2731
|
+
if (error) {
|
|
2732
|
+
await tick();
|
|
2733
|
+
if (linkInputs[0]) linkInputs[0].focus();
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
1075
2737
|
</script>
|
|
1076
2738
|
|
|
2739
|
+
<svelte:head>
|
|
2740
|
+
<title>Login - ${opts.name}</title>
|
|
2741
|
+
</svelte:head>
|
|
2742
|
+
|
|
1077
2743
|
<!-- TODO: Add login page template (PIN inputs, setup wizard, device verification modal) -->
|
|
1078
2744
|
`;
|
|
1079
2745
|
}
|
|
1080
|
-
|
|
1081
|
-
|
|
2746
|
+
/**
|
|
2747
|
+
* Generate the email confirmation page component that handles token
|
|
2748
|
+
* verification and cross-tab broadcast.
|
|
2749
|
+
*
|
|
2750
|
+
* @returns The Svelte component source for `src/routes/confirm/+page.svelte`.
|
|
2751
|
+
*/
|
|
2752
|
+
function generateConfirmPage(opts) {
|
|
2753
|
+
return `<!--
|
|
2754
|
+
@fileoverview Email confirmation page — token verification, BroadcastChannel
|
|
2755
|
+
relay, and close/redirect flow.
|
|
2756
|
+
|
|
2757
|
+
Supabase email links land here with \`?token_hash=...&type=...\` query
|
|
2758
|
+
params. The page verifies the token, broadcasts the result to the
|
|
2759
|
+
originating tab via BroadcastChannel, and either tells the user they
|
|
2760
|
+
can close the tab or redirects them to the app root.
|
|
2761
|
+
-->
|
|
2762
|
+
<script lang="ts">
|
|
1082
2763
|
import { onMount } from 'svelte';
|
|
1083
2764
|
import { goto } from '$app/navigation';
|
|
1084
2765
|
import { page } from '$app/stores';
|
|
1085
2766
|
import { handleEmailConfirmation, broadcastAuthConfirmed } from '@prabhask5/stellar-engine/kit';
|
|
1086
2767
|
|
|
2768
|
+
// ==========================================================================
|
|
2769
|
+
// STATE
|
|
2770
|
+
// ==========================================================================
|
|
2771
|
+
|
|
2772
|
+
/** Current page state — drives which UI variant is rendered. */
|
|
1087
2773
|
let status: 'verifying' | 'success' | 'error' | 'redirecting' | 'can_close' = 'verifying';
|
|
2774
|
+
|
|
2775
|
+
/** Human-readable error message when verification fails. */
|
|
1088
2776
|
let errorMessage = '';
|
|
1089
2777
|
|
|
1090
|
-
|
|
2778
|
+
// ==========================================================================
|
|
2779
|
+
// CONSTANTS
|
|
2780
|
+
// ==========================================================================
|
|
2781
|
+
|
|
2782
|
+
/** BroadcastChannel name shared with the login page. */
|
|
2783
|
+
const CHANNEL_NAME = '${opts.prefix}-auth-channel';
|
|
2784
|
+
|
|
2785
|
+
// ==========================================================================
|
|
2786
|
+
// LIFECYCLE
|
|
2787
|
+
// ==========================================================================
|
|
1091
2788
|
|
|
1092
2789
|
onMount(async () => {
|
|
2790
|
+
/* ── Read Supabase callback params ── */
|
|
1093
2791
|
const tokenHash = $page.url.searchParams.get('token_hash');
|
|
1094
2792
|
const type = $page.url.searchParams.get('type');
|
|
1095
2793
|
|
|
2794
|
+
/* ── Token present → verify it ── */
|
|
1096
2795
|
if (tokenHash && type) {
|
|
1097
2796
|
const result = await handleEmailConfirmation(
|
|
1098
2797
|
tokenHash,
|
|
@@ -1106,37 +2805,110 @@ function generateConfirmPage() {
|
|
|
1106
2805
|
}
|
|
1107
2806
|
|
|
1108
2807
|
status = 'success';
|
|
2808
|
+
/* Brief pause so the user sees the success state */
|
|
1109
2809
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
1110
2810
|
}
|
|
1111
2811
|
|
|
2812
|
+
/* ── Notify the originating tab and decide next action ── */
|
|
1112
2813
|
const tabResult = await broadcastAuthConfirmed(CHANNEL_NAME, type || 'signup');
|
|
1113
2814
|
if (tabResult === 'can_close') {
|
|
1114
2815
|
status = 'can_close';
|
|
1115
2816
|
} else if (tabResult === 'no_broadcast') {
|
|
1116
|
-
|
|
2817
|
+
focusOrRedirect();
|
|
1117
2818
|
}
|
|
1118
2819
|
});
|
|
2820
|
+
|
|
2821
|
+
// ==========================================================================
|
|
2822
|
+
// HELPERS
|
|
2823
|
+
// ==========================================================================
|
|
2824
|
+
|
|
2825
|
+
/**
|
|
2826
|
+
* Broadcast a confirmation event to any listening login tab, then
|
|
2827
|
+
* attempt to close this browser tab. Falls back to a static
|
|
2828
|
+
* "you can close this tab" message when \`window.close()\` is denied.
|
|
2829
|
+
*/
|
|
2830
|
+
async function focusOrRedirect() {
|
|
2831
|
+
status = 'redirecting';
|
|
2832
|
+
|
|
2833
|
+
const type = $page.url.searchParams.get('type') || 'signup';
|
|
2834
|
+
|
|
2835
|
+
const result = await broadcastAuthConfirmed(CHANNEL_NAME, type);
|
|
2836
|
+
|
|
2837
|
+
if (result === 'no_broadcast') {
|
|
2838
|
+
/* BroadcastChannel unsupported — redirect to home directly */
|
|
2839
|
+
goto('/', { replaceState: true });
|
|
2840
|
+
} else {
|
|
2841
|
+
/* 'can_close' — window.close() was blocked by browser */
|
|
2842
|
+
setTimeout(() => {
|
|
2843
|
+
status = 'can_close';
|
|
2844
|
+
}, 200);
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
1119
2847
|
</script>
|
|
1120
2848
|
|
|
2849
|
+
<svelte:head>
|
|
2850
|
+
<title>Confirming... - ${opts.name}</title>
|
|
2851
|
+
</svelte:head>
|
|
2852
|
+
|
|
1121
2853
|
<!-- TODO: Add confirmation page template (verifying/success/error/can_close states) -->
|
|
1122
2854
|
`;
|
|
1123
2855
|
}
|
|
2856
|
+
// ---------------------------------------------------------------------------
|
|
2857
|
+
// API ENDPOINT GENERATORS
|
|
2858
|
+
// ---------------------------------------------------------------------------
|
|
2859
|
+
/**
|
|
2860
|
+
* Generate the `/api/config` server endpoint that returns runtime config.
|
|
2861
|
+
*
|
|
2862
|
+
* @returns The TypeScript source for `src/routes/api/config/+server.ts`.
|
|
2863
|
+
*/
|
|
1124
2864
|
function generateConfigServer() {
|
|
1125
|
-
return
|
|
2865
|
+
return `/**
|
|
2866
|
+
* Config API Endpoint — \`GET /api/config\`
|
|
2867
|
+
*
|
|
2868
|
+
* Returns the runtime configuration object (Supabase URL, anon key, app
|
|
2869
|
+
* settings) that the client fetches on first load via \`initConfig()\`.
|
|
2870
|
+
*/
|
|
2871
|
+
|
|
2872
|
+
import { json } from '@sveltejs/kit';
|
|
1126
2873
|
import { getServerConfig } from '@prabhask5/stellar-engine/kit';
|
|
1127
2874
|
import type { RequestHandler } from './$types';
|
|
1128
2875
|
|
|
2876
|
+
/**
|
|
2877
|
+
* Serve the runtime config as JSON.
|
|
2878
|
+
*
|
|
2879
|
+
* @returns A JSON response containing the server-side config object.
|
|
2880
|
+
*/
|
|
1129
2881
|
export const GET: RequestHandler = async () => {
|
|
1130
2882
|
return json(getServerConfig());
|
|
1131
2883
|
};
|
|
1132
2884
|
`;
|
|
1133
2885
|
}
|
|
2886
|
+
/**
|
|
2887
|
+
* Generate the `/api/setup/deploy` server endpoint for Vercel deployment.
|
|
2888
|
+
*
|
|
2889
|
+
* @returns The TypeScript source for `src/routes/api/setup/deploy/+server.ts`.
|
|
2890
|
+
*/
|
|
1134
2891
|
function generateDeployServer() {
|
|
1135
|
-
return
|
|
2892
|
+
return `/**
|
|
2893
|
+
* Vercel Deploy API Endpoint — \`POST /api/setup/deploy\`
|
|
2894
|
+
*
|
|
2895
|
+
* Accepts Supabase credentials and a Vercel token, then sets the
|
|
2896
|
+
* corresponding environment variables on the Vercel project and triggers
|
|
2897
|
+
* a redeployment so the new config takes effect.
|
|
2898
|
+
*/
|
|
2899
|
+
|
|
2900
|
+
import { json } from '@sveltejs/kit';
|
|
1136
2901
|
import { deployToVercel } from '@prabhask5/stellar-engine/kit';
|
|
1137
2902
|
import type { RequestHandler } from './$types';
|
|
1138
2903
|
|
|
2904
|
+
/**
|
|
2905
|
+
* Deploy Supabase credentials to Vercel environment variables.
|
|
2906
|
+
*
|
|
2907
|
+
* @param params - SvelteKit request event.
|
|
2908
|
+
* @returns JSON result with success/failure and optional error message.
|
|
2909
|
+
*/
|
|
1139
2910
|
export const POST: RequestHandler = async ({ request }) => {
|
|
2911
|
+
/* ── Parse and validate request body ── */
|
|
1140
2912
|
const { supabaseUrl, supabaseAnonKey, vercelToken } = await request.json();
|
|
1141
2913
|
|
|
1142
2914
|
if (!supabaseUrl || !supabaseAnonKey || !vercelToken) {
|
|
@@ -1146,6 +2918,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|
|
1146
2918
|
);
|
|
1147
2919
|
}
|
|
1148
2920
|
|
|
2921
|
+
/* ── Ensure we're running on Vercel ── */
|
|
1149
2922
|
const projectId = process.env.VERCEL_PROJECT_ID;
|
|
1150
2923
|
if (!projectId) {
|
|
1151
2924
|
return json(
|
|
@@ -1154,59 +2927,140 @@ export const POST: RequestHandler = async ({ request }) => {
|
|
|
1154
2927
|
);
|
|
1155
2928
|
}
|
|
1156
2929
|
|
|
2930
|
+
/* ── Delegate to engine ── */
|
|
1157
2931
|
const result = await deployToVercel({ vercelToken, projectId, supabaseUrl, supabaseAnonKey });
|
|
1158
2932
|
return json(result);
|
|
1159
2933
|
};
|
|
1160
2934
|
`;
|
|
1161
2935
|
}
|
|
2936
|
+
/**
|
|
2937
|
+
* Generate the `/api/setup/validate` server endpoint for Supabase credential validation.
|
|
2938
|
+
*
|
|
2939
|
+
* @returns The TypeScript source for `src/routes/api/setup/validate/+server.ts`.
|
|
2940
|
+
*/
|
|
1162
2941
|
function generateValidateServer() {
|
|
1163
|
-
return
|
|
2942
|
+
return `/**
|
|
2943
|
+
* Supabase Credential Validation Endpoint — \`POST /api/setup/validate\`
|
|
2944
|
+
*
|
|
2945
|
+
* Accepts a Supabase URL and anon key, attempts a lightweight query
|
|
2946
|
+
* against the project, and returns whether the credentials are valid.
|
|
2947
|
+
* Used by the setup wizard before saving config.
|
|
2948
|
+
*/
|
|
2949
|
+
|
|
2950
|
+
import { createValidateHandler } from '@prabhask5/stellar-engine/kit';
|
|
1164
2951
|
import type { RequestHandler } from './$types';
|
|
1165
2952
|
|
|
2953
|
+
/** Validate Supabase credentials — delegates to stellar-engine's handler factory. */
|
|
1166
2954
|
export const POST: RequestHandler = createValidateHandler();
|
|
1167
2955
|
`;
|
|
1168
2956
|
}
|
|
2957
|
+
// ---------------------------------------------------------------------------
|
|
2958
|
+
// CATCHALL & PROTECTED LAYOUT GENERATORS
|
|
2959
|
+
// ---------------------------------------------------------------------------
|
|
2960
|
+
/**
|
|
2961
|
+
* Generate a catch-all route that redirects unknown paths to the home page.
|
|
2962
|
+
*
|
|
2963
|
+
* @returns The TypeScript source for `src/routes/[...catchall]/+page.ts`.
|
|
2964
|
+
*/
|
|
1169
2965
|
function generateCatchallPage() {
|
|
1170
|
-
return
|
|
2966
|
+
return `/**
|
|
2967
|
+
* Catch-All Route Handler — \`[...catchall]/+page.ts\`
|
|
2968
|
+
*
|
|
2969
|
+
* Matches any URL that doesn't correspond to a defined route and
|
|
2970
|
+
* redirects the user back to the home page. Prevents 404 errors
|
|
2971
|
+
* for deep links that no longer exist.
|
|
2972
|
+
*/
|
|
2973
|
+
|
|
2974
|
+
import { redirect } from '@sveltejs/kit';
|
|
1171
2975
|
|
|
2976
|
+
/**
|
|
2977
|
+
* Redirect unknown paths to the app root.
|
|
2978
|
+
*/
|
|
1172
2979
|
export function load() {
|
|
1173
2980
|
redirect(302, '/');
|
|
1174
2981
|
}
|
|
1175
2982
|
`;
|
|
1176
2983
|
}
|
|
2984
|
+
/**
|
|
2985
|
+
* Generate the protected route group's `+layout.ts` with auth guards
|
|
2986
|
+
* that redirect unauthenticated users to `/login`.
|
|
2987
|
+
*
|
|
2988
|
+
* @returns The TypeScript source for `src/routes/(protected)/+layout.ts`.
|
|
2989
|
+
*/
|
|
1177
2990
|
function generateProtectedLayoutTs() {
|
|
1178
|
-
return
|
|
2991
|
+
return `/**
|
|
2992
|
+
* @fileoverview Protected Layout Load Function — Auth Guard
|
|
2993
|
+
*
|
|
2994
|
+
* Runs on every navigation into the \`(protected)\` route group.
|
|
2995
|
+
* Resolves the current authentication state via \`stellar-engine\` and
|
|
2996
|
+
* redirects unauthenticated users to \`/login\` (preserving the intended
|
|
2997
|
+
* destination as a \`?redirect=\` query parameter).
|
|
2998
|
+
*
|
|
2999
|
+
* On the server (SSR), returns a neutral "unauthenticated" payload so
|
|
3000
|
+
* that the actual auth check happens exclusively in the browser where
|
|
3001
|
+
* cookies / local storage are available.
|
|
3002
|
+
*/
|
|
3003
|
+
|
|
3004
|
+
import { redirect } from '@sveltejs/kit';
|
|
1179
3005
|
import { browser } from '$app/environment';
|
|
1180
|
-
import {
|
|
1181
|
-
import type {
|
|
3006
|
+
import { resolveProtectedLayout } from '@prabhask5/stellar-engine/kit';
|
|
3007
|
+
import type { ProtectedLayoutData } from '@prabhask5/stellar-engine/kit';
|
|
1182
3008
|
import type { LayoutLoad } from './$types';
|
|
1183
3009
|
|
|
1184
|
-
export
|
|
1185
|
-
session: Session | null;
|
|
1186
|
-
authMode: AuthMode;
|
|
1187
|
-
offlineProfile: OfflineCredentials | null;
|
|
1188
|
-
}
|
|
3010
|
+
export type { ProtectedLayoutData };
|
|
1189
3011
|
|
|
3012
|
+
/**
|
|
3013
|
+
* SvelteKit universal \`load\` function for the \`(protected)\` layout.
|
|
3014
|
+
*
|
|
3015
|
+
* - **Browser**: resolves the auth state; redirects to \`/login\` if \`authMode\` is \`'none'\`.
|
|
3016
|
+
* - **Server**: short-circuits with a neutral payload (auth is client-side only).
|
|
3017
|
+
*
|
|
3018
|
+
* @param url — The current page URL, used to build the redirect target.
|
|
3019
|
+
* @returns Resolved \`ProtectedLayoutData\` for downstream pages and layouts.
|
|
3020
|
+
*/
|
|
1190
3021
|
export const load: LayoutLoad = async ({ url }): Promise<ProtectedLayoutData> => {
|
|
1191
3022
|
if (browser) {
|
|
1192
|
-
const
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
returnUrl && returnUrl !== '/'
|
|
1197
|
-
? \`/login?redirect=\${encodeURIComponent(returnUrl)}\`
|
|
1198
|
-
: '/login';
|
|
1199
|
-
throw redirect(302, loginUrl);
|
|
3023
|
+
const { data, redirectUrl } = await resolveProtectedLayout(url);
|
|
3024
|
+
|
|
3025
|
+
if (redirectUrl) {
|
|
3026
|
+
throw redirect(302, redirectUrl);
|
|
1200
3027
|
}
|
|
1201
|
-
|
|
3028
|
+
|
|
3029
|
+
return data;
|
|
1202
3030
|
}
|
|
3031
|
+
|
|
3032
|
+
/* SSR fallback — no auth info available on the server */
|
|
1203
3033
|
return { session: null, authMode: 'none', offlineProfile: null };
|
|
1204
3034
|
};
|
|
1205
3035
|
`;
|
|
1206
3036
|
}
|
|
3037
|
+
/**
|
|
3038
|
+
* Generate the protected route group's `+layout.svelte` pass-through component.
|
|
3039
|
+
*
|
|
3040
|
+
* @returns The Svelte component source for `src/routes/(protected)/+layout.svelte`.
|
|
3041
|
+
*/
|
|
1207
3042
|
function generateProtectedLayoutSvelte() {
|
|
1208
|
-
return
|
|
3043
|
+
return `<!--
|
|
3044
|
+
@fileoverview Protected Layout Component — wraps the \`(protected)\` route group.
|
|
3045
|
+
|
|
3046
|
+
Every page inside \`src/routes/(protected)/\` inherits this layout. The auth
|
|
3047
|
+
guard lives in \`+layout.ts\`; this component is a pass-through that renders
|
|
3048
|
+
the child page and provides a hook for protected-area chrome (backgrounds,
|
|
3049
|
+
breadcrumbs, etc.).
|
|
3050
|
+
-->
|
|
3051
|
+
<script lang="ts">
|
|
3052
|
+
// ==========================================================================
|
|
3053
|
+
// IMPORTS
|
|
3054
|
+
// ==========================================================================
|
|
3055
|
+
|
|
3056
|
+
// (no additional imports needed for the pass-through layout)
|
|
3057
|
+
|
|
3058
|
+
// ==========================================================================
|
|
3059
|
+
// PROPS
|
|
3060
|
+
// ==========================================================================
|
|
3061
|
+
|
|
1209
3062
|
interface Props {
|
|
3063
|
+
/** Default slot content (the routed page component). */
|
|
1210
3064
|
children?: import('svelte').Snippet;
|
|
1211
3065
|
}
|
|
1212
3066
|
|
|
@@ -1215,18 +3069,42 @@ function generateProtectedLayoutSvelte() {
|
|
|
1215
3069
|
// TODO: Add conditional page backgrounds or other protected-area chrome
|
|
1216
3070
|
</script>
|
|
1217
3071
|
|
|
3072
|
+
<!-- Render child route content -->
|
|
1218
3073
|
{@render children?.()}
|
|
1219
3074
|
`;
|
|
1220
3075
|
}
|
|
1221
|
-
|
|
1222
|
-
|
|
3076
|
+
/**
|
|
3077
|
+
* Generate the profile page component with TODO stubs for user settings,
|
|
3078
|
+
* device management, and debug tools.
|
|
3079
|
+
*
|
|
3080
|
+
* @returns The Svelte component source for `src/routes/(protected)/profile/+page.svelte`.
|
|
3081
|
+
*/
|
|
3082
|
+
function generateProfilePage(opts) {
|
|
3083
|
+
return `<!--
|
|
3084
|
+
@fileoverview Profile & settings page.
|
|
3085
|
+
|
|
3086
|
+
Capabilities:
|
|
3087
|
+
- View / edit display name and avatar
|
|
3088
|
+
- Change email address (with re-verification)
|
|
3089
|
+
- Change unlock gate type (PIN length, pattern, etc.)
|
|
3090
|
+
- Manage trusted devices (view, revoke)
|
|
3091
|
+
- Toggle debug mode
|
|
3092
|
+
- Reset local database (destructive — requires confirmation)
|
|
3093
|
+
-->
|
|
3094
|
+
<script lang="ts">
|
|
3095
|
+
// =============================================================================
|
|
3096
|
+
// IMPORTS
|
|
3097
|
+
// =============================================================================
|
|
3098
|
+
|
|
1223
3099
|
import { goto } from '$app/navigation';
|
|
1224
3100
|
import {
|
|
1225
3101
|
changeSingleUserGate,
|
|
1226
3102
|
updateSingleUserProfile,
|
|
1227
3103
|
getSingleUserInfo,
|
|
1228
3104
|
changeSingleUserEmail,
|
|
1229
|
-
completeSingleUserEmailChange
|
|
3105
|
+
completeSingleUserEmailChange,
|
|
3106
|
+
resolveUserId,
|
|
3107
|
+
resolveAvatarInitial
|
|
1230
3108
|
} from '@prabhask5/stellar-engine/auth';
|
|
1231
3109
|
import { authState } from '@prabhask5/stellar-engine/stores';
|
|
1232
3110
|
import { isDebugMode, setDebugMode } from '@prabhask5/stellar-engine/utils';
|
|
@@ -1236,33 +3114,667 @@ function generateProfilePage() {
|
|
|
1236
3114
|
removeTrustedDevice,
|
|
1237
3115
|
getCurrentDeviceId
|
|
1238
3116
|
} from '@prabhask5/stellar-engine';
|
|
3117
|
+
import type { TrustedDevice } from '@prabhask5/stellar-engine';
|
|
3118
|
+
import { onMount } from 'svelte';
|
|
3119
|
+
|
|
3120
|
+
// =============================================================================
|
|
3121
|
+
// COMPONENT STATE
|
|
3122
|
+
// =============================================================================
|
|
3123
|
+
|
|
3124
|
+
/* ── Profile form fields ──── */
|
|
3125
|
+
let firstName = $state('');
|
|
3126
|
+
let lastName = $state('');
|
|
3127
|
+
|
|
3128
|
+
/* ── Gate (6-digit code) change — digit-array approach ──── */
|
|
3129
|
+
let oldCodeDigits = $state(['', '', '', '', '', '']);
|
|
3130
|
+
let newCodeDigits = $state(['', '', '', '', '', '']);
|
|
3131
|
+
let confirmCodeDigits = $state(['', '', '', '', '', '']);
|
|
3132
|
+
|
|
3133
|
+
/** Concatenated old code string → derived from individual digit inputs */
|
|
3134
|
+
const oldCode = $derived(oldCodeDigits.join(''));
|
|
3135
|
+
/** Concatenated new code string → derived from individual digit inputs */
|
|
3136
|
+
const newCode = $derived(newCodeDigits.join(''));
|
|
3137
|
+
/** Concatenated confirm code string — must match \`newCode\` */
|
|
3138
|
+
const confirmNewCode = $derived(confirmCodeDigits.join(''));
|
|
3139
|
+
|
|
3140
|
+
/* ── Input element refs for auto-focus advancement ──── */
|
|
3141
|
+
let oldCodeInputs: HTMLInputElement[] = $state([]);
|
|
3142
|
+
let newCodeInputs: HTMLInputElement[] = $state([]);
|
|
3143
|
+
let confirmCodeInputs: HTMLInputElement[] = $state([]);
|
|
3144
|
+
|
|
3145
|
+
/* ── Email change fields ──── */
|
|
3146
|
+
let currentEmail = $state('');
|
|
3147
|
+
let newEmail = $state('');
|
|
3148
|
+
let emailLoading = $state(false);
|
|
3149
|
+
let emailError = $state<string | null>(null);
|
|
3150
|
+
let emailSuccess = $state<string | null>(null);
|
|
3151
|
+
/** Whether the email confirmation modal overlay is visible */
|
|
3152
|
+
let showEmailConfirmationModal = $state(false);
|
|
3153
|
+
/** Seconds remaining before the user can re-send the confirmation email */
|
|
3154
|
+
let emailResendCooldown = $state(0);
|
|
3155
|
+
|
|
3156
|
+
/* ── General UI / feedback state ──── */
|
|
3157
|
+
let profileLoading = $state(false);
|
|
3158
|
+
let codeLoading = $state(false);
|
|
3159
|
+
let profileError = $state<string | null>(null);
|
|
3160
|
+
let profileSuccess = $state<string | null>(null);
|
|
3161
|
+
let codeError = $state<string | null>(null);
|
|
3162
|
+
let codeSuccess = $state<string | null>(null);
|
|
3163
|
+
let debugMode = $state(isDebugMode());
|
|
3164
|
+
let resetting = $state(false);
|
|
3165
|
+
|
|
3166
|
+
/* ── Debug tools loading flags ──── */
|
|
3167
|
+
let forceSyncing = $state(false);
|
|
3168
|
+
let triggeringSyncManual = $state(false);
|
|
3169
|
+
let resettingCursor = $state(false);
|
|
3170
|
+
let checkingConnection = $state(false);
|
|
3171
|
+
let viewingTombstones = $state(false);
|
|
3172
|
+
let cleaningTombstones = $state(false);
|
|
3173
|
+
|
|
3174
|
+
/* ── Trusted devices ──── */
|
|
3175
|
+
let trustedDevices = $state<TrustedDevice[]>([]);
|
|
3176
|
+
let currentDeviceId = $state('');
|
|
3177
|
+
let devicesLoading = $state(true);
|
|
3178
|
+
/** ID of the device currently being removed — shows spinner on that row */
|
|
3179
|
+
let removingDeviceId = $state<string | null>(null);
|
|
3180
|
+
|
|
3181
|
+
// =============================================================================
|
|
3182
|
+
// LIFECYCLE
|
|
3183
|
+
// =============================================================================
|
|
3184
|
+
|
|
3185
|
+
/** Populate form fields from the engine and load trusted devices on mount. */
|
|
3186
|
+
onMount(async () => {
|
|
3187
|
+
const info = await getSingleUserInfo();
|
|
3188
|
+
if (info) {
|
|
3189
|
+
firstName = (info.profile.firstName as string) || '';
|
|
3190
|
+
lastName = (info.profile.lastName as string) || '';
|
|
3191
|
+
currentEmail = info.email || '';
|
|
3192
|
+
}
|
|
3193
|
+
|
|
3194
|
+
// Load trusted devices
|
|
3195
|
+
currentDeviceId = getCurrentDeviceId();
|
|
3196
|
+
try {
|
|
3197
|
+
const userId = resolveUserId($authState?.session, $authState?.offlineProfile);
|
|
3198
|
+
if (userId) {
|
|
3199
|
+
trustedDevices = await getTrustedDevices(userId);
|
|
3200
|
+
}
|
|
3201
|
+
} catch {
|
|
3202
|
+
// Ignore errors loading devices
|
|
3203
|
+
}
|
|
3204
|
+
devicesLoading = false;
|
|
3205
|
+
});
|
|
3206
|
+
|
|
3207
|
+
// =============================================================================
|
|
3208
|
+
// DIGIT INPUT HELPERS
|
|
3209
|
+
// =============================================================================
|
|
3210
|
+
|
|
3211
|
+
/**
|
|
3212
|
+
* Handle single-digit input in a code field.
|
|
3213
|
+
* Auto-advances focus to the next input when a digit is entered.
|
|
3214
|
+
* @param digits - Reactive digit array to mutate
|
|
3215
|
+
* @param index - Position in the 6-digit code (0–5)
|
|
3216
|
+
* @param event - Native input event
|
|
3217
|
+
* @param inputs - Array of \`<input>\` refs for focus management
|
|
3218
|
+
*/
|
|
3219
|
+
function handleDigitInput(
|
|
3220
|
+
digits: string[],
|
|
3221
|
+
index: number,
|
|
3222
|
+
event: Event,
|
|
3223
|
+
inputs: HTMLInputElement[]
|
|
3224
|
+
) {
|
|
3225
|
+
const input = event.target as HTMLInputElement;
|
|
3226
|
+
const value = input.value.replace(/[^0-9]/g, '');
|
|
3227
|
+
if (value.length > 0) {
|
|
3228
|
+
digits[index] = value.charAt(value.length - 1);
|
|
3229
|
+
input.value = digits[index];
|
|
3230
|
+
if (index < 5 && inputs[index + 1]) {
|
|
3231
|
+
inputs[index + 1].focus();
|
|
3232
|
+
}
|
|
3233
|
+
} else {
|
|
3234
|
+
digits[index] = '';
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
3237
|
+
|
|
3238
|
+
/**
|
|
3239
|
+
* Handle Backspace in a digit field — moves focus backward when the current
|
|
3240
|
+
* digit is already empty.
|
|
3241
|
+
* @param digits - Reactive digit array to mutate
|
|
3242
|
+
* @param index - Position in the 6-digit code (0–5)
|
|
3243
|
+
* @param event - Native keyboard event
|
|
3244
|
+
* @param inputs - Array of \`<input>\` refs for focus management
|
|
3245
|
+
*/
|
|
3246
|
+
function handleDigitKeydown(
|
|
3247
|
+
digits: string[],
|
|
3248
|
+
index: number,
|
|
3249
|
+
event: KeyboardEvent,
|
|
3250
|
+
inputs: HTMLInputElement[]
|
|
3251
|
+
) {
|
|
3252
|
+
if (event.key === 'Backspace') {
|
|
3253
|
+
if (digits[index] === '' && index > 0 && inputs[index - 1]) {
|
|
3254
|
+
inputs[index - 1].focus();
|
|
3255
|
+
digits[index - 1] = '';
|
|
3256
|
+
} else {
|
|
3257
|
+
digits[index] = '';
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
}
|
|
3261
|
+
|
|
3262
|
+
/**
|
|
3263
|
+
* Handle paste into a digit field — distributes pasted digits across all 6 inputs.
|
|
3264
|
+
* @param digits - Reactive digit array to mutate
|
|
3265
|
+
* @param event - Native clipboard event
|
|
3266
|
+
* @param inputs - Array of \`<input>\` refs for focus management
|
|
3267
|
+
*/
|
|
3268
|
+
function handleDigitPaste(digits: string[], event: ClipboardEvent, inputs: HTMLInputElement[]) {
|
|
3269
|
+
event.preventDefault();
|
|
3270
|
+
const pasted = (event.clipboardData?.getData('text') || '').replace(/[^0-9]/g, '');
|
|
3271
|
+
for (let i = 0; i < 6 && i < pasted.length; i++) {
|
|
3272
|
+
digits[i] = pasted[i];
|
|
3273
|
+
if (inputs[i]) inputs[i].value = pasted[i];
|
|
3274
|
+
}
|
|
3275
|
+
const focusIndex = Math.min(pasted.length, 5);
|
|
3276
|
+
if (inputs[focusIndex]) inputs[focusIndex].focus();
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
// =============================================================================
|
|
3280
|
+
// FORM SUBMISSION HANDLERS
|
|
3281
|
+
// =============================================================================
|
|
3282
|
+
|
|
3283
|
+
/**
|
|
3284
|
+
* Submit profile name changes to the engine and update the auth store
|
|
3285
|
+
* so the navbar reflects changes immediately.
|
|
3286
|
+
* @param e - Form submit event
|
|
3287
|
+
*/
|
|
3288
|
+
async function handleProfileSubmit(e: Event) {
|
|
3289
|
+
e.preventDefault();
|
|
3290
|
+
profileLoading = true;
|
|
3291
|
+
profileError = null;
|
|
3292
|
+
profileSuccess = null;
|
|
3293
|
+
|
|
3294
|
+
try {
|
|
3295
|
+
await updateSingleUserProfile({
|
|
3296
|
+
firstName: firstName.trim(),
|
|
3297
|
+
lastName: lastName.trim()
|
|
3298
|
+
});
|
|
3299
|
+
// Update auth state to immediately reflect changes in navbar
|
|
3300
|
+
authState.updateUserProfile({ first_name: firstName.trim(), last_name: lastName.trim() });
|
|
3301
|
+
profileSuccess = 'Profile updated successfully';
|
|
3302
|
+
setTimeout(() => (profileSuccess = null), 3000);
|
|
3303
|
+
} catch (err: unknown) {
|
|
3304
|
+
profileError = err instanceof Error ? err.message : 'Failed to update profile';
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3307
|
+
profileLoading = false;
|
|
3308
|
+
}
|
|
3309
|
+
|
|
3310
|
+
/**
|
|
3311
|
+
* Validate and submit a 6-digit gate code change.
|
|
3312
|
+
* Resets all digit arrays on success.
|
|
3313
|
+
* @param e - Form submit event
|
|
3314
|
+
*/
|
|
3315
|
+
async function handleCodeSubmit(e: Event) {
|
|
3316
|
+
e.preventDefault();
|
|
3317
|
+
|
|
3318
|
+
if (oldCode.length !== 6) {
|
|
3319
|
+
codeError = 'Please enter your current 6-digit code';
|
|
3320
|
+
return;
|
|
3321
|
+
}
|
|
3322
|
+
|
|
3323
|
+
if (newCode.length !== 6) {
|
|
3324
|
+
codeError = 'Please enter a new 6-digit code';
|
|
3325
|
+
return;
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3328
|
+
if (newCode !== confirmNewCode) {
|
|
3329
|
+
codeError = 'New codes do not match';
|
|
3330
|
+
return;
|
|
3331
|
+
}
|
|
3332
|
+
|
|
3333
|
+
codeLoading = true;
|
|
3334
|
+
codeError = null;
|
|
3335
|
+
codeSuccess = null;
|
|
3336
|
+
|
|
3337
|
+
try {
|
|
3338
|
+
await changeSingleUserGate(oldCode, newCode);
|
|
3339
|
+
codeSuccess = 'Code changed successfully';
|
|
3340
|
+
oldCodeDigits = ['', '', '', '', '', ''];
|
|
3341
|
+
newCodeDigits = ['', '', '', '', '', ''];
|
|
3342
|
+
confirmCodeDigits = ['', '', '', '', '', ''];
|
|
3343
|
+
setTimeout(() => (codeSuccess = null), 3000);
|
|
3344
|
+
} catch (err: unknown) {
|
|
3345
|
+
codeError = err instanceof Error ? err.message : 'Failed to change code';
|
|
3346
|
+
}
|
|
3347
|
+
|
|
3348
|
+
codeLoading = false;
|
|
3349
|
+
}
|
|
3350
|
+
|
|
3351
|
+
// =============================================================================
|
|
3352
|
+
// EMAIL CHANGE FLOW
|
|
3353
|
+
// =============================================================================
|
|
3354
|
+
|
|
3355
|
+
/**
|
|
3356
|
+
* Initiate an email change — sends a confirmation link to the new address.
|
|
3357
|
+
* Opens the confirmation modal and starts listening for the cross-tab
|
|
3358
|
+
* \`BroadcastChannel\` auth event.
|
|
3359
|
+
* @param e - Form submit event
|
|
3360
|
+
*/
|
|
3361
|
+
async function handleEmailSubmit(e: Event) {
|
|
3362
|
+
e.preventDefault();
|
|
3363
|
+
emailError = null;
|
|
3364
|
+
emailSuccess = null;
|
|
3365
|
+
|
|
3366
|
+
if (!newEmail.trim()) {
|
|
3367
|
+
emailError = 'Please enter a new email address';
|
|
3368
|
+
return;
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
if (newEmail.trim() === currentEmail) {
|
|
3372
|
+
emailError = 'New email is the same as your current email';
|
|
3373
|
+
return;
|
|
3374
|
+
}
|
|
3375
|
+
|
|
3376
|
+
emailLoading = true;
|
|
3377
|
+
|
|
3378
|
+
try {
|
|
3379
|
+
const result = await changeSingleUserEmail(newEmail.trim());
|
|
3380
|
+
if (result.error) {
|
|
3381
|
+
emailError = result.error;
|
|
3382
|
+
} else if (result.confirmationRequired) {
|
|
3383
|
+
showEmailConfirmationModal = true;
|
|
3384
|
+
startResendCooldown();
|
|
3385
|
+
listenForEmailConfirmation();
|
|
3386
|
+
}
|
|
3387
|
+
} catch (err: unknown) {
|
|
3388
|
+
emailError = err instanceof Error ? err.message : 'Failed to change email';
|
|
3389
|
+
}
|
|
3390
|
+
|
|
3391
|
+
emailLoading = false;
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
/** Start a 30-second countdown preventing repeated confirmation emails. */
|
|
3395
|
+
function startResendCooldown() {
|
|
3396
|
+
emailResendCooldown = 30;
|
|
3397
|
+
const interval = setInterval(() => {
|
|
3398
|
+
emailResendCooldown--;
|
|
3399
|
+
if (emailResendCooldown <= 0) clearInterval(interval);
|
|
3400
|
+
}, 1000);
|
|
3401
|
+
}
|
|
3402
|
+
|
|
3403
|
+
/** Re-send the email change confirmation (guarded by cooldown). */
|
|
3404
|
+
async function handleResendEmailChange() {
|
|
3405
|
+
if (emailResendCooldown > 0) return;
|
|
3406
|
+
try {
|
|
3407
|
+
await changeSingleUserEmail(newEmail.trim());
|
|
3408
|
+
startResendCooldown();
|
|
3409
|
+
} catch {
|
|
3410
|
+
// Ignore resend errors
|
|
3411
|
+
}
|
|
3412
|
+
}
|
|
3413
|
+
|
|
3414
|
+
/**
|
|
3415
|
+
* Listen on a \`BroadcastChannel\` for the confirmation tab to signal
|
|
3416
|
+
* that the user clicked the email-change link. Once received, complete
|
|
3417
|
+
* the email change server-side and update local state.
|
|
3418
|
+
*/
|
|
3419
|
+
function listenForEmailConfirmation() {
|
|
3420
|
+
if (!('BroadcastChannel' in window)) return;
|
|
3421
|
+
const channel = new BroadcastChannel('stellar-auth-channel');
|
|
3422
|
+
channel.onmessage = async (event) => {
|
|
3423
|
+
if (
|
|
3424
|
+
event.data?.type === 'AUTH_CONFIRMED' &&
|
|
3425
|
+
event.data?.verificationType === 'email_change'
|
|
3426
|
+
) {
|
|
3427
|
+
// Bring this tab to the foreground before the confirm tab closes
|
|
3428
|
+
window.focus();
|
|
3429
|
+
const result = await completeSingleUserEmailChange();
|
|
3430
|
+
if (!result.error && result.newEmail) {
|
|
3431
|
+
currentEmail = result.newEmail;
|
|
3432
|
+
emailSuccess = 'Email changed successfully';
|
|
3433
|
+
newEmail = '';
|
|
3434
|
+
setTimeout(() => (emailSuccess = null), 5000);
|
|
3435
|
+
} else {
|
|
3436
|
+
emailError = result.error || 'Failed to complete email change';
|
|
3437
|
+
}
|
|
3438
|
+
showEmailConfirmationModal = false;
|
|
3439
|
+
channel.close();
|
|
3440
|
+
}
|
|
3441
|
+
};
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
/** Close the email confirmation modal without completing the change. */
|
|
3445
|
+
function dismissEmailModal() {
|
|
3446
|
+
showEmailConfirmationModal = false;
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3449
|
+
// =============================================================================
|
|
3450
|
+
// ADMINISTRATION HANDLERS
|
|
3451
|
+
// =============================================================================
|
|
3452
|
+
|
|
3453
|
+
/** Toggle debug mode on/off — requires a page refresh to take full effect. */
|
|
3454
|
+
function toggleDebugMode() {
|
|
3455
|
+
debugMode = !debugMode;
|
|
3456
|
+
setDebugMode(debugMode);
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3459
|
+
/** Navigate back to the main tasks view. */
|
|
3460
|
+
function goBack() {
|
|
3461
|
+
goto('/tasks');
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
/**
|
|
3465
|
+
* Delete and recreate the local IndexedDB, then reload the page.
|
|
3466
|
+
* Session is preserved in localStorage so the app will re-hydrate.
|
|
3467
|
+
*/
|
|
3468
|
+
async function handleResetDatabase() {
|
|
3469
|
+
if (
|
|
3470
|
+
!confirm(
|
|
3471
|
+
'This will delete all local data and reload. Your data will be re-synced from the server. Continue?'
|
|
3472
|
+
)
|
|
3473
|
+
) {
|
|
3474
|
+
return;
|
|
3475
|
+
}
|
|
3476
|
+
resetting = true;
|
|
3477
|
+
try {
|
|
3478
|
+
await resetDatabase();
|
|
3479
|
+
// Reload the page — session is preserved in localStorage, so the app
|
|
3480
|
+
// will re-create the DB, fetch config from Supabase, and re-hydrate.
|
|
3481
|
+
window.location.reload();
|
|
3482
|
+
} catch (err) {
|
|
3483
|
+
alert('Reset failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
|
|
3484
|
+
resetting = false;
|
|
3485
|
+
}
|
|
3486
|
+
}
|
|
3487
|
+
|
|
3488
|
+
/**
|
|
3489
|
+
* Remove a trusted device by ID and update the local list.
|
|
3490
|
+
* @param id - Database ID of the trusted device row
|
|
3491
|
+
*/
|
|
3492
|
+
async function handleRemoveDevice(id: string) {
|
|
3493
|
+
removingDeviceId = id;
|
|
3494
|
+
try {
|
|
3495
|
+
await removeTrustedDevice(id);
|
|
3496
|
+
trustedDevices = trustedDevices.filter((d) => d.id !== id);
|
|
3497
|
+
} catch {
|
|
3498
|
+
// Ignore errors
|
|
3499
|
+
}
|
|
3500
|
+
removingDeviceId = null;
|
|
3501
|
+
}
|
|
3502
|
+
|
|
3503
|
+
// =============================================================================
|
|
3504
|
+
// DEBUG TOOL HANDLERS
|
|
3505
|
+
// =============================================================================
|
|
3506
|
+
|
|
3507
|
+
/**
|
|
3508
|
+
* Cast \`window\` to an untyped record for accessing runtime-injected
|
|
3509
|
+
* debug helpers (e.g., \`__${opts.prefix}Sync\`, \`__${opts.prefix}SyncStats\`).
|
|
3510
|
+
* @returns The global \`window\` as a loose \`Record\`
|
|
3511
|
+
*/
|
|
3512
|
+
function getDebugWindow(): Record<string, unknown> {
|
|
3513
|
+
return window as unknown as Record<string, unknown>;
|
|
3514
|
+
}
|
|
3515
|
+
|
|
3516
|
+
/** Resets the sync cursor and re-downloads all data from Supabase. */
|
|
3517
|
+
async function handleForceFullSync() {
|
|
3518
|
+
if (
|
|
3519
|
+
!confirm(
|
|
3520
|
+
'This will reset the sync cursor and re-download all data from the server. Continue?'
|
|
3521
|
+
)
|
|
3522
|
+
)
|
|
3523
|
+
return;
|
|
3524
|
+
forceSyncing = true;
|
|
3525
|
+
try {
|
|
3526
|
+
const fn = getDebugWindow().__${opts.prefix}Sync as
|
|
3527
|
+
| { forceFullSync: () => Promise<void> }
|
|
3528
|
+
| undefined;
|
|
3529
|
+
if (fn?.forceFullSync) {
|
|
3530
|
+
await fn.forceFullSync();
|
|
3531
|
+
alert('Force full sync complete.');
|
|
3532
|
+
} else {
|
|
3533
|
+
alert('Debug mode must be enabled and the page refreshed to use this tool.');
|
|
3534
|
+
}
|
|
3535
|
+
} catch (err) {
|
|
3536
|
+
alert('Force full sync failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
|
|
3537
|
+
}
|
|
3538
|
+
forceSyncing = false;
|
|
3539
|
+
}
|
|
3540
|
+
|
|
3541
|
+
/** Manually trigger a single push/pull sync cycle. */
|
|
3542
|
+
async function handleTriggerSync() {
|
|
3543
|
+
triggeringSyncManual = true;
|
|
3544
|
+
try {
|
|
3545
|
+
const fn = getDebugWindow().__${opts.prefix}Sync as { sync: () => Promise<void> } | undefined;
|
|
3546
|
+
if (fn?.sync) {
|
|
3547
|
+
await fn.sync();
|
|
3548
|
+
alert('Sync cycle complete.');
|
|
3549
|
+
} else {
|
|
3550
|
+
alert('Debug mode must be enabled and the page refreshed to use this tool.');
|
|
3551
|
+
}
|
|
3552
|
+
} catch (err) {
|
|
3553
|
+
alert('Sync failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
|
|
3554
|
+
}
|
|
3555
|
+
triggeringSyncManual = false;
|
|
3556
|
+
}
|
|
3557
|
+
|
|
3558
|
+
/** Reset the sync cursor so the next cycle pulls all remote data. */
|
|
3559
|
+
async function handleResetSyncCursor() {
|
|
3560
|
+
resettingCursor = true;
|
|
3561
|
+
try {
|
|
3562
|
+
const fn = getDebugWindow().__${opts.prefix}Sync as
|
|
3563
|
+
| { resetSyncCursor: () => Promise<void> }
|
|
3564
|
+
| undefined;
|
|
3565
|
+
if (fn?.resetSyncCursor) {
|
|
3566
|
+
await fn.resetSyncCursor();
|
|
3567
|
+
alert('Sync cursor reset. The next sync will pull all data.');
|
|
3568
|
+
} else {
|
|
3569
|
+
alert('Debug mode must be enabled and the page refreshed to use this tool.');
|
|
3570
|
+
}
|
|
3571
|
+
} catch (err) {
|
|
3572
|
+
alert('Reset cursor failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
|
|
3573
|
+
}
|
|
3574
|
+
resettingCursor = false;
|
|
3575
|
+
}
|
|
3576
|
+
|
|
3577
|
+
/** Test connectivity to Supabase and show the result in an alert. */
|
|
3578
|
+
async function handleCheckConnection() {
|
|
3579
|
+
checkingConnection = true;
|
|
3580
|
+
try {
|
|
3581
|
+
const fn = getDebugWindow().__${opts.prefix}Sync as
|
|
3582
|
+
| {
|
|
3583
|
+
checkConnection: () => Promise<{
|
|
3584
|
+
connected: boolean;
|
|
3585
|
+
error?: string;
|
|
3586
|
+
records?: number;
|
|
3587
|
+
}>;
|
|
3588
|
+
}
|
|
3589
|
+
| undefined;
|
|
3590
|
+
if (fn?.checkConnection) {
|
|
3591
|
+
const result = await fn.checkConnection();
|
|
3592
|
+
if (result.connected) {
|
|
3593
|
+
alert('Connection OK. Supabase is reachable.');
|
|
3594
|
+
} else {
|
|
3595
|
+
alert('Connection failed: ' + (result.error || 'Unknown error'));
|
|
3596
|
+
}
|
|
3597
|
+
} else {
|
|
3598
|
+
alert('Debug mode must be enabled and the page refreshed to use this tool.');
|
|
3599
|
+
}
|
|
3600
|
+
} catch (err) {
|
|
3601
|
+
alert('Connection check failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
|
|
3602
|
+
}
|
|
3603
|
+
checkingConnection = false;
|
|
3604
|
+
}
|
|
3605
|
+
|
|
3606
|
+
/** Display current sync cursor and pending operations count in an alert. */
|
|
3607
|
+
function handleGetSyncStatus() {
|
|
3608
|
+
const fn = getDebugWindow().__${opts.prefix}Sync as
|
|
3609
|
+
| { getStatus: () => { cursor: unknown; pendingOps: Promise<number> } }
|
|
3610
|
+
| undefined;
|
|
3611
|
+
if (fn?.getStatus) {
|
|
3612
|
+
const status = fn.getStatus();
|
|
3613
|
+
const cursorDisplay =
|
|
3614
|
+
typeof status.cursor === 'object'
|
|
3615
|
+
? JSON.stringify(status.cursor)
|
|
3616
|
+
: String(status.cursor || 'None');
|
|
3617
|
+
status.pendingOps.then((count: number) => {
|
|
3618
|
+
alert(\`Sync Status:\n\nCursor: \${cursorDisplay}\nPending operations: \${count}\`);
|
|
3619
|
+
});
|
|
3620
|
+
} else {
|
|
3621
|
+
alert('Debug mode must be enabled and the page refreshed to use this tool.');
|
|
3622
|
+
}
|
|
3623
|
+
}
|
|
3624
|
+
|
|
3625
|
+
/** Show the realtime WebSocket connection state and health. */
|
|
3626
|
+
function handleRealtimeStatus() {
|
|
3627
|
+
const fn = getDebugWindow().__${opts.prefix}Sync as
|
|
3628
|
+
| { realtimeStatus: () => { state: string; healthy: boolean } }
|
|
3629
|
+
| undefined;
|
|
3630
|
+
if (fn?.realtimeStatus) {
|
|
3631
|
+
const status = fn.realtimeStatus();
|
|
3632
|
+
alert(
|
|
3633
|
+
\`Realtime Status:\n\nState: \${status.state}\nHealthy: \${status.healthy ? 'Yes' : 'No'}\`
|
|
3634
|
+
);
|
|
3635
|
+
} else {
|
|
3636
|
+
alert('Debug mode must be enabled and the page refreshed to use this tool.');
|
|
3637
|
+
}
|
|
3638
|
+
}
|
|
3639
|
+
|
|
3640
|
+
/** Display sync cycle stats in an alert; full details logged to console. */
|
|
3641
|
+
function handleViewSyncStats() {
|
|
3642
|
+
const fn = getDebugWindow().__${opts.prefix}SyncStats as
|
|
3643
|
+
| (() => { totalSyncCycles: number; recentMinute: number; recent: unknown[] })
|
|
3644
|
+
| undefined;
|
|
3645
|
+
if (fn) {
|
|
3646
|
+
const stats = fn();
|
|
3647
|
+
alert(
|
|
3648
|
+
\`Sync Stats:\n\nTotal cycles: \${stats.totalSyncCycles}\nCycles in last minute: \${stats.recentMinute}\nRecent cycles logged to console.\`
|
|
3649
|
+
);
|
|
3650
|
+
} else {
|
|
3651
|
+
alert('Debug mode must be enabled and the page refreshed to use this tool.');
|
|
3652
|
+
}
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
/** Display data-transfer / egress stats; per-table breakdown in console. */
|
|
3656
|
+
function handleViewEgress() {
|
|
3657
|
+
const fn = getDebugWindow().__${opts.prefix}Egress as
|
|
3658
|
+
| (() => { totalFormatted: string; totalRecords: number; sessionStart: string })
|
|
3659
|
+
| undefined;
|
|
3660
|
+
if (fn) {
|
|
3661
|
+
const stats = fn();
|
|
3662
|
+
alert(
|
|
3663
|
+
\`Egress Stats:\n\nTotal data transferred: \${stats.totalFormatted}\nTotal records: \${stats.totalRecords}\nSession started: \${new Date(stats.sessionStart).toLocaleString()}\n\nFull breakdown logged to console.\`
|
|
3664
|
+
);
|
|
3665
|
+
} else {
|
|
3666
|
+
alert('Debug mode must be enabled and the page refreshed to use this tool.');
|
|
3667
|
+
}
|
|
3668
|
+
}
|
|
3669
|
+
|
|
3670
|
+
/** Log soft-deleted record counts per table to the browser console. */
|
|
3671
|
+
async function handleViewTombstones() {
|
|
3672
|
+
viewingTombstones = true;
|
|
3673
|
+
try {
|
|
3674
|
+
const fn = getDebugWindow().__${opts.prefix}Tombstones as
|
|
3675
|
+
| ((opts?: { cleanup?: boolean; force?: boolean }) => Promise<void>)
|
|
3676
|
+
| undefined;
|
|
3677
|
+
if (fn) {
|
|
3678
|
+
await fn();
|
|
3679
|
+
alert('Tombstone details logged to console. Open DevTools to view.');
|
|
3680
|
+
} else {
|
|
3681
|
+
alert('Debug mode must be enabled and the page refreshed to use this tool.');
|
|
3682
|
+
}
|
|
3683
|
+
} catch (err) {
|
|
3684
|
+
alert('View tombstones failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
|
|
3685
|
+
}
|
|
3686
|
+
viewingTombstones = false;
|
|
3687
|
+
}
|
|
3688
|
+
|
|
3689
|
+
/** Permanently remove old soft-deleted records from local + remote DBs. */
|
|
3690
|
+
async function handleCleanupTombstones() {
|
|
3691
|
+
if (
|
|
3692
|
+
!confirm(
|
|
3693
|
+
'This will permanently remove old soft-deleted records from local and server databases. Continue?'
|
|
3694
|
+
)
|
|
3695
|
+
)
|
|
3696
|
+
return;
|
|
3697
|
+
cleaningTombstones = true;
|
|
3698
|
+
try {
|
|
3699
|
+
const fn = getDebugWindow().__${opts.prefix}Tombstones as
|
|
3700
|
+
| ((opts?: { cleanup?: boolean; force?: boolean }) => Promise<void>)
|
|
3701
|
+
| undefined;
|
|
3702
|
+
if (fn) {
|
|
3703
|
+
await fn({ cleanup: true });
|
|
3704
|
+
alert('Tombstone cleanup complete. Details logged to console.');
|
|
3705
|
+
} else {
|
|
3706
|
+
alert('Debug mode must be enabled and the page refreshed to use this tool.');
|
|
3707
|
+
}
|
|
3708
|
+
} catch (err) {
|
|
3709
|
+
alert('Tombstone cleanup failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
|
|
3710
|
+
}
|
|
3711
|
+
cleaningTombstones = false;
|
|
3712
|
+
}
|
|
1239
3713
|
|
|
1240
|
-
|
|
3714
|
+
/** Dispatch a custom event that the app shell listens for to sign out on mobile. */
|
|
3715
|
+
function handleMobileSignOut() {
|
|
3716
|
+
window.dispatchEvent(new CustomEvent('${opts.prefix}:signout'));
|
|
3717
|
+
}
|
|
1241
3718
|
</script>
|
|
1242
3719
|
|
|
3720
|
+
<svelte:head>
|
|
3721
|
+
<title>Profile - ${opts.name}</title>
|
|
3722
|
+
</svelte:head>
|
|
3723
|
+
|
|
1243
3724
|
<!-- TODO: Add profile page template (forms, cards, device list, debug tools) -->
|
|
1244
3725
|
`;
|
|
1245
3726
|
}
|
|
3727
|
+
// ---------------------------------------------------------------------------
|
|
3728
|
+
// COMPONENT GENERATORS
|
|
3729
|
+
// ---------------------------------------------------------------------------
|
|
3730
|
+
/**
|
|
3731
|
+
* Generate the UpdatePrompt component that monitors the service worker
|
|
3732
|
+
* lifecycle and shows an "update available" notification.
|
|
3733
|
+
*
|
|
3734
|
+
* @returns The Svelte component source for `src/lib/components/UpdatePrompt.svelte`.
|
|
3735
|
+
*/
|
|
1246
3736
|
function generateUpdatePromptComponent() {
|
|
1247
3737
|
return `<script lang="ts">
|
|
1248
3738
|
/**
|
|
1249
3739
|
* @fileoverview UpdatePrompt — service-worker update notification.
|
|
1250
3740
|
*
|
|
1251
|
-
*
|
|
1252
|
-
*
|
|
3741
|
+
* Detects when a new service worker version is waiting to activate and
|
|
3742
|
+
* shows an "update available" prompt. Detection relies on six signals:
|
|
3743
|
+
* 1. \`statechange\` on the installing SW → catches updates during the visit
|
|
3744
|
+
* 2. \`updatefound\` on the registration → catches background installs
|
|
3745
|
+
* 3. \`visibilitychange\` → re-checks when the tab becomes visible
|
|
3746
|
+
* 4. \`online\` event → re-checks when connectivity is restored
|
|
3747
|
+
* 5. Periodic interval → fallback for iOS standalone mode
|
|
3748
|
+
* 6. Initial check on mount → catches SWs that installed before this component
|
|
3749
|
+
*
|
|
3750
|
+
* Uses \`monitorSwLifecycle()\` from stellar-engine to wire up all six, and
|
|
3751
|
+
* \`handleSwUpdate()\` to send SKIP_WAITING + reload on user confirmation.
|
|
1253
3752
|
*/
|
|
1254
3753
|
|
|
3754
|
+
// ==========================================================================
|
|
3755
|
+
// IMPORTS
|
|
3756
|
+
// ==========================================================================
|
|
3757
|
+
|
|
1255
3758
|
import { onMount, onDestroy } from 'svelte';
|
|
1256
3759
|
import { monitorSwLifecycle, handleSwUpdate } from '@prabhask5/stellar-engine/kit';
|
|
1257
3760
|
|
|
3761
|
+
// ==========================================================================
|
|
3762
|
+
// COMPONENT STATE
|
|
3763
|
+
// ==========================================================================
|
|
3764
|
+
|
|
1258
3765
|
/** Whether the update prompt is visible */
|
|
1259
3766
|
let showPrompt = $state(false);
|
|
1260
3767
|
|
|
1261
3768
|
/** Guard flag to prevent double-reload */
|
|
1262
3769
|
let reloading = false;
|
|
1263
3770
|
|
|
3771
|
+
/** Cleanup function returned by monitorSwLifecycle */
|
|
1264
3772
|
let cleanup: (() => void) | null = null;
|
|
1265
3773
|
|
|
3774
|
+
// ==========================================================================
|
|
3775
|
+
// SERVICE WORKER MONITORING
|
|
3776
|
+
// ==========================================================================
|
|
3777
|
+
|
|
1266
3778
|
onMount(() => {
|
|
1267
3779
|
cleanup = monitorSwLifecycle({
|
|
1268
3780
|
onUpdateAvailable: () => {
|
|
@@ -1275,6 +3787,10 @@ function generateUpdatePromptComponent() {
|
|
|
1275
3787
|
cleanup?.();
|
|
1276
3788
|
});
|
|
1277
3789
|
|
|
3790
|
+
// ==========================================================================
|
|
3791
|
+
// ACTION HANDLERS
|
|
3792
|
+
// ==========================================================================
|
|
3793
|
+
|
|
1278
3794
|
/**
|
|
1279
3795
|
* Apply the update: sends SKIP_WAITING to the waiting SW,
|
|
1280
3796
|
* waits for controllerchange, then reloads the page.
|
|
@@ -1310,8 +3826,24 @@ function generateUpdatePromptComponent() {
|
|
|
1310
3826
|
-->
|
|
1311
3827
|
`;
|
|
1312
3828
|
}
|
|
3829
|
+
// ---------------------------------------------------------------------------
|
|
3830
|
+
// TYPE RE-EXPORT GENERATOR
|
|
3831
|
+
// ---------------------------------------------------------------------------
|
|
3832
|
+
/**
|
|
3833
|
+
* Generate the app types barrel file that re-exports stellar-engine types
|
|
3834
|
+
* and provides a location for app-specific type definitions.
|
|
3835
|
+
*
|
|
3836
|
+
* @returns The TypeScript source for `src/lib/types.ts`.
|
|
3837
|
+
*/
|
|
1313
3838
|
function generateAppTypes() {
|
|
1314
|
-
return
|
|
3839
|
+
return `/**
|
|
3840
|
+
* @fileoverview Type barrel — re-exports from stellar-engine plus app-specific types.
|
|
3841
|
+
*
|
|
3842
|
+
* Conventions used by stellar-engine tables:
|
|
3843
|
+
* - \`deleted\` — soft-delete flag (boolean, default \`false\`)
|
|
3844
|
+
* - \`_version\` — optimistic concurrency counter (integer, starts at 1)
|
|
3845
|
+
* - \`device_id\` — originating device identifier for conflict resolution
|
|
3846
|
+
*/
|
|
1315
3847
|
export type { SyncStatus, AuthMode, OfflineCredentials } from '@prabhask5/stellar-engine/types';
|
|
1316
3848
|
|
|
1317
3849
|
// TODO: Add app-specific type definitions below
|
|
@@ -1320,6 +3852,21 @@ export type { SyncStatus, AuthMode, OfflineCredentials } from '@prabhask5/stella
|
|
|
1320
3852
|
// =============================================================================
|
|
1321
3853
|
// MAIN FUNCTION
|
|
1322
3854
|
// =============================================================================
|
|
3855
|
+
/**
|
|
3856
|
+
* Main entry point for the CLI scaffolding tool.
|
|
3857
|
+
*
|
|
3858
|
+
* **Execution flow:**
|
|
3859
|
+
* 1. Parse CLI arguments into {@link InstallOptions}.
|
|
3860
|
+
* 2. Write `package.json` (if missing).
|
|
3861
|
+
* 3. Run `npm install` to fetch dependencies.
|
|
3862
|
+
* 4. Write all template files (config, routes, components, assets, docs).
|
|
3863
|
+
* 5. Initialise Husky and write the pre-commit hook.
|
|
3864
|
+
* 6. Print a summary of created/skipped files and next steps.
|
|
3865
|
+
*
|
|
3866
|
+
* @returns A promise that resolves when scaffolding is complete.
|
|
3867
|
+
*
|
|
3868
|
+
* @throws {Error} If `npm install` or `npx husky init` fails.
|
|
3869
|
+
*/
|
|
1323
3870
|
async function main() {
|
|
1324
3871
|
const opts = parseArgs(process.argv);
|
|
1325
3872
|
const cwd = process.cwd();
|
|
@@ -1369,21 +3916,21 @@ async function main() {
|
|
|
1369
3916
|
['src/app.d.ts', generateAppDts(opts)],
|
|
1370
3917
|
// Route files
|
|
1371
3918
|
['src/routes/+layout.ts', generateRootLayoutTs(opts)],
|
|
1372
|
-
['src/routes/+layout.svelte', generateRootLayoutSvelte()],
|
|
1373
|
-
['src/routes/+page.svelte', generateHomePage()],
|
|
1374
|
-
['src/routes/+error.svelte', generateErrorPage()],
|
|
3919
|
+
['src/routes/+layout.svelte', generateRootLayoutSvelte(opts)],
|
|
3920
|
+
['src/routes/+page.svelte', generateHomePage(opts)],
|
|
3921
|
+
['src/routes/+error.svelte', generateErrorPage(opts)],
|
|
1375
3922
|
['src/routes/setup/+page.ts', generateSetupPageTs()],
|
|
1376
|
-
['src/routes/setup/+page.svelte', generateSetupPageSvelte()],
|
|
1377
|
-
['src/routes/policy/+page.svelte', generatePolicyPage()],
|
|
1378
|
-
['src/routes/login/+page.svelte', generateLoginPage()],
|
|
1379
|
-
['src/routes/confirm/+page.svelte', generateConfirmPage()],
|
|
3923
|
+
['src/routes/setup/+page.svelte', generateSetupPageSvelte(opts)],
|
|
3924
|
+
['src/routes/policy/+page.svelte', generatePolicyPage(opts)],
|
|
3925
|
+
['src/routes/login/+page.svelte', generateLoginPage(opts)],
|
|
3926
|
+
['src/routes/confirm/+page.svelte', generateConfirmPage(opts)],
|
|
1380
3927
|
['src/routes/api/config/+server.ts', generateConfigServer()],
|
|
1381
3928
|
['src/routes/api/setup/deploy/+server.ts', generateDeployServer()],
|
|
1382
3929
|
['src/routes/api/setup/validate/+server.ts', generateValidateServer()],
|
|
1383
3930
|
['src/routes/[...catchall]/+page.ts', generateCatchallPage()],
|
|
1384
3931
|
['src/routes/(protected)/+layout.ts', generateProtectedLayoutTs()],
|
|
1385
3932
|
['src/routes/(protected)/+layout.svelte', generateProtectedLayoutSvelte()],
|
|
1386
|
-
['src/routes/(protected)/profile/+page.svelte', generateProfilePage()],
|
|
3933
|
+
['src/routes/(protected)/profile/+page.svelte', generateProfilePage(opts)],
|
|
1387
3934
|
['src/lib/types.ts', generateAppTypes()],
|
|
1388
3935
|
// Component files
|
|
1389
3936
|
['src/lib/components/UpdatePrompt.svelte', generateUpdatePromptComponent()]
|
|
@@ -1394,7 +3941,7 @@ async function main() {
|
|
|
1394
3941
|
// 4. Set up husky
|
|
1395
3942
|
console.log('Setting up husky...');
|
|
1396
3943
|
execSync('npx husky init', { stdio: 'inherit', cwd });
|
|
1397
|
-
|
|
3944
|
+
/* Overwrite the default pre-commit (husky init creates one with "npm test") */
|
|
1398
3945
|
const preCommitPath = join(cwd, '.husky/pre-commit');
|
|
1399
3946
|
writeFileSync(preCommitPath, generateHuskyPreCommit(), 'utf-8');
|
|
1400
3947
|
createdFiles.push('.husky/pre-commit');
|