@almadar/orb 4.6.4 → 4.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/shells/almadar-shell/package.json +17 -3
- package/shells/almadar-shell/packages/client/eslint.config.cjs +34 -1
- package/shells/almadar-shell/packages/client/package.json +28 -26
- package/shells/almadar-shell/packages/client/src/App.tsx +24 -3
- package/shells/almadar-shell/packages/client/src/config/firebase.ts +23 -2
- package/shells/almadar-shell/packages/client/src/config/mockAuth.ts +257 -0
- package/shells/almadar-shell/packages/client/src/features/auth/AuthContext.tsx +22 -9
- package/shells/almadar-shell/packages/client/src/features/auth/authService.ts +28 -7
- package/shells/almadar-shell/packages/client/src/features/auth/components/PersonaSwitcher.tsx +50 -0
- package/shells/almadar-shell/packages/client/src/features/auth/components/ProtectedRoute.tsx +9 -0
- package/shells/almadar-shell/packages/client/src/features/auth/index.ts +2 -1
- package/shells/almadar-shell/packages/client/src/features/auth/types.ts +18 -2
- package/shells/almadar-shell/packages/client/src/index.css +4 -20
- package/shells/almadar-shell/packages/client/tailwind.config.js +12 -2
- package/shells/almadar-shell/packages/server/eslint.config.cjs +4 -0
- package/shells/almadar-shell/packages/server/package.json +13 -10
- package/shells/almadar-shell/packages/server/pnpm-lock.yaml +4723 -0
- package/shells/almadar-shell/packages/server/src/app.ts +58 -3
- package/shells/almadar-shell/packages/server/src/hooks-providers.ts +11 -0
- package/shells/almadar-shell/packages/server/src/index.ts +41 -2
- package/shells/almadar-shell/packages/server/src/services/clients.ts +17 -0
- package/shells/almadar-shell/packages/server/src/sse.ts +20 -0
- package/shells/almadar-shell/packages/server/src/types/express.d.ts +3 -7
- package/shells/almadar-shell/packages/server/tsconfig.json +0 -1
- package/shells/almadar-shell/packages/shared/package.json +1 -1
- package/shells/almadar-shell/pnpm-lock.yaml +3957 -1090
- package/shells/almadar-shell-hono/package.json +12 -6
- package/shells/almadar-shell-hono/packages/client/package.json +6 -6
- package/shells/almadar-shell-hono/packages/client/src/App.tsx +1 -5
- package/shells/almadar-shell-hono/packages/client/src/config/firebase.ts +23 -2
- package/shells/almadar-shell-hono/packages/client/src/features/auth/AuthContext.tsx +11 -6
- package/shells/almadar-shell-hono/packages/client/src/features/auth/authService.ts +10 -7
- package/shells/almadar-shell-hono/packages/client/src/features/auth/components/ProtectedRoute.tsx +9 -0
- package/shells/almadar-shell-hono/packages/client/tailwind.config.js +4 -1
- package/shells/almadar-shell-hono/packages/server/package.json +5 -5
- package/shells/almadar-shell-hono/packages/server/src/app.ts +39 -0
- package/shells/almadar-shell-hono/packages/server/src/hooks-providers.ts +11 -0
- package/shells/almadar-shell-hono/packages/server/src/index.ts +41 -2
- package/shells/almadar-shell-hono/packages/server/src/serve.ts +4 -2
- package/shells/almadar-shell-hono/packages/server/src/services/clients.ts +17 -0
- package/shells/almadar-shell-hono/packages/server/src/sse.ts +20 -0
- package/shells/almadar-shell-hono/packages/server/tsconfig.json +0 -1
- package/shells/almadar-shell-hono/pnpm-lock.yaml +4348 -1235
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@almadar/orb",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.8.0",
|
|
4
4
|
"description": "Orb CLI - deterministic when it can be, intelligent when it needs to be",
|
|
5
5
|
"license": "BSL-1.1",
|
|
6
6
|
"repository": {
|
|
@@ -49,10 +49,10 @@
|
|
|
49
49
|
"node": ">=16.0.0"
|
|
50
50
|
},
|
|
51
51
|
"optionalDependencies": {
|
|
52
|
-
"@almadar/orb-darwin-x64": "4.
|
|
53
|
-
"@almadar/orb-darwin-arm64": "4.
|
|
54
|
-
"@almadar/orb-linux-x64": "4.
|
|
55
|
-
"@almadar/orb-linux-arm64": "4.
|
|
56
|
-
"@almadar/orb-windows-x64": "4.
|
|
52
|
+
"@almadar/orb-darwin-x64": "4.8.0",
|
|
53
|
+
"@almadar/orb-darwin-arm64": "4.8.0",
|
|
54
|
+
"@almadar/orb-linux-x64": "4.8.0",
|
|
55
|
+
"@almadar/orb-linux-arm64": "4.8.0",
|
|
56
|
+
"@almadar/orb-windows-x64": "4.8.0"
|
|
57
57
|
}
|
|
58
58
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@almadar/shell",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.109.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Minimal full-stack shell template for Almadar applications",
|
|
6
6
|
"packageManager": "pnpm@10.30.3",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"build": "pnpm run -r build",
|
|
10
10
|
"typecheck": "turbo run typecheck",
|
|
11
11
|
"lint": "turbo run lint",
|
|
12
|
+
"prepack": "node -e \"const fs=require('fs');if(fs.existsSync('packages'))for(const p of fs.readdirSync('packages'))for(const j of ['.turbo','package-lock.json']){const t='packages/'+p+'/'+j;if(fs.existsSync(t))fs.rmSync(t,{recursive:true,force:true});}\"",
|
|
12
13
|
"prepare": "git rev-parse --git-dir > /dev/null 2>&1 && git config core.hooksPath .githooks || true"
|
|
13
14
|
},
|
|
14
15
|
"devDependencies": {
|
|
@@ -19,11 +20,24 @@
|
|
|
19
20
|
"overrides": {
|
|
20
21
|
"@tootallnate/once": "^3.0.1",
|
|
21
22
|
"esbuild": "^0.25.0",
|
|
22
|
-
"flatted": "^3.4.1"
|
|
23
|
+
"flatted": "^3.4.1",
|
|
24
|
+
"protobufjs": ">=7.5.8 <8",
|
|
25
|
+
"node-forge": "^1.4.0",
|
|
26
|
+
"fast-xml-parser": "^5.7.0",
|
|
27
|
+
"fast-xml-builder": ">=1.1.7",
|
|
28
|
+
"express>path-to-regexp": "0.1.13",
|
|
29
|
+
"qs": "^6.15.2",
|
|
30
|
+
"lodash-es": "^4.18.0"
|
|
23
31
|
}
|
|
24
32
|
},
|
|
25
33
|
"files": [
|
|
26
|
-
"
|
|
34
|
+
"packages",
|
|
35
|
+
"locales",
|
|
36
|
+
"pnpm-workspace.yaml",
|
|
37
|
+
"turbo.json",
|
|
38
|
+
"pnpm-lock.yaml",
|
|
39
|
+
".env.example",
|
|
40
|
+
".gitignore"
|
|
27
41
|
],
|
|
28
42
|
"repository": {
|
|
29
43
|
"type": "git",
|
|
@@ -1,11 +1,31 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
const tsParser = require("@typescript-eslint/parser");
|
|
3
3
|
const almadarPlugin = require("@almadar/eslint-plugin");
|
|
4
|
+
// react-hooks plugin registered so the codegen-emitted
|
|
5
|
+
// `// eslint-disable-next-line react-hooks/exhaustive-deps` comments are
|
|
6
|
+
// well-formed (eslint errors on disable directives that reference
|
|
7
|
+
// unknown rules). The actual rules are left off — enabling them would
|
|
8
|
+
// surface a backlog of dep-array warnings the codegen knows are
|
|
9
|
+
// intentional. Tightening is a separate codegen-fix pass.
|
|
10
|
+
let reactHooksPlugin;
|
|
11
|
+
try {
|
|
12
|
+
reactHooksPlugin = require("eslint-plugin-react-hooks");
|
|
13
|
+
} catch (_) {
|
|
14
|
+
reactHooksPlugin = { rules: { "exhaustive-deps": { meta: {}, create: () => ({}) }, "rules-of-hooks": { meta: {}, create: () => ({}) } } };
|
|
15
|
+
}
|
|
4
16
|
|
|
5
17
|
module.exports = [
|
|
6
18
|
{ ignores: ["dist/**", "node_modules/**", "**/*.test.ts", "**/*.test.tsx"] },
|
|
7
19
|
{
|
|
8
20
|
files: ["src/**/*.ts", "src/**/*.tsx"],
|
|
21
|
+
// The codegen emits `// eslint-disable-next-line react-hooks/exhaustive-deps`
|
|
22
|
+
// ahead of every mount-only effect's `[]` dep array. Until the real
|
|
23
|
+
// `eslint-plugin-react-hooks` is wired (it would fire the rule and
|
|
24
|
+
// make these directives meaningful), the shim's no-op create() means
|
|
25
|
+
// the rule never reports — so ESLint flags the directives as
|
|
26
|
+
// "Unused eslint-disable directive". That's a false alarm at this
|
|
27
|
+
// stage; suppress it so the strict-error rule set still hard-fails.
|
|
28
|
+
linterOptions: { reportUnusedDisableDirectives: "off" },
|
|
9
29
|
languageOptions: {
|
|
10
30
|
parser: tsParser,
|
|
11
31
|
parserOptions: {
|
|
@@ -14,9 +34,22 @@ module.exports = [
|
|
|
14
34
|
sourceType: "module",
|
|
15
35
|
},
|
|
16
36
|
},
|
|
17
|
-
plugins: { almadar: almadarPlugin },
|
|
37
|
+
plugins: { almadar: almadarPlugin, "react-hooks": reactHooksPlugin },
|
|
18
38
|
rules: {
|
|
39
|
+
// Zero escape hatches in emitted shell code. Each rule pairs with a
|
|
40
|
+
// codegen-template fix in orbital-rust (see docs/Almadar_Compiler_Gaps.md):
|
|
41
|
+
// - no-as-any: catches `as any` casts.
|
|
42
|
+
// - no-unknown-type: catches `: unknown` annotations.
|
|
43
|
+
// Service results are typed via the integrations
|
|
44
|
+
// contract; every other "unknown" is a typed
|
|
45
|
+
// shape the codegen knows.
|
|
46
|
+
// - no-record-string-unknown: catches `as Record<string, unknown>` casts
|
|
47
|
+
// the codegen used to index into entity rows by
|
|
48
|
+
// string key. The entity has a typed interface
|
|
49
|
+
// — emit against that.
|
|
19
50
|
"almadar/no-as-any": "error",
|
|
51
|
+
"almadar/no-unknown-type": "error",
|
|
52
|
+
"almadar/no-record-string-unknown": "error",
|
|
20
53
|
"almadar/no-import-generated": "error",
|
|
21
54
|
},
|
|
22
55
|
},
|
|
@@ -1,59 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@almadar/shell-client",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.13.15",
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "vite",
|
|
8
8
|
"build": "tsc && vite build",
|
|
9
9
|
"typecheck": "tsc --noEmit",
|
|
10
|
-
"lint": "eslint src/",
|
|
10
|
+
"lint": "eslint --max-warnings 0 src/",
|
|
11
11
|
"preview": "vite preview",
|
|
12
12
|
"test": "vitest run --passWithNoTests",
|
|
13
13
|
"test:watch": "vitest"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@almadar/
|
|
17
|
-
"@almadar/
|
|
18
|
-
"@almadar/
|
|
19
|
-
"@almadar/patterns": "
|
|
20
|
-
"@almadar/
|
|
21
|
-
"
|
|
16
|
+
"@almadar/core": "^10.92.0",
|
|
17
|
+
"@almadar/evaluator": "^2.45.0",
|
|
18
|
+
"@almadar/logger": "^1.12.0",
|
|
19
|
+
"@almadar/patterns": "^2.101.0",
|
|
20
|
+
"@almadar/syntax": "^1.17.0",
|
|
21
|
+
"@almadar/ui": "^6.21.0",
|
|
22
|
+
"@monaco-editor/react": "^4.7.0",
|
|
23
|
+
"@react-three/drei": "^9.92.0",
|
|
24
|
+
"@react-three/fiber": "^9.0.0",
|
|
25
|
+
"@react-three/postprocessing": "^3.0.0",
|
|
22
26
|
"@tanstack/react-query": "^5.67.3",
|
|
27
|
+
"firebase": "^11.4.0",
|
|
28
|
+
"leaflet": "^1.9.4",
|
|
29
|
+
"lucide-react": "^0.344.0",
|
|
30
|
+
"monaco-editor": "^0.52.2",
|
|
31
|
+
"postprocessing": "^6.39.0",
|
|
23
32
|
"react": "^19.0.0",
|
|
24
33
|
"react-dom": "^19.0.0",
|
|
25
|
-
"react-
|
|
26
|
-
"
|
|
34
|
+
"react-force-graph-2d": "^1.25.5",
|
|
35
|
+
"react-leaflet": "^4.2.1",
|
|
27
36
|
"react-markdown": "^9.0.1",
|
|
28
|
-
"
|
|
29
|
-
"remark-math": "^6.0.0",
|
|
37
|
+
"react-router-dom": "^7.3.0",
|
|
30
38
|
"rehype-katex": "^7.0.1",
|
|
31
39
|
"rehype-raw": "^7.0.0",
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"monaco-editor": "^0.52.2",
|
|
35
|
-
"leaflet": "^1.9.4",
|
|
36
|
-
"react-leaflet": "^4.2.1",
|
|
37
|
-
"lucide-react": "^0.344.0",
|
|
40
|
+
"remark-gfm": "^4.0.1",
|
|
41
|
+
"remark-math": "^6.0.0",
|
|
38
42
|
"three": "^0.160.0",
|
|
39
|
-
"
|
|
40
|
-
"@react-three/drei": "^9.92.0",
|
|
41
|
-
"@react-three/postprocessing": "^3.0.0",
|
|
42
|
-
"postprocessing": "^6.39.0"
|
|
43
|
+
"zustand": "^5.0.3"
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|
|
45
|
-
"@almadar/eslint-plugin": "
|
|
46
|
-
"@typescript-eslint/parser": "8.56.0",
|
|
47
|
-
"eslint": "10.0.0",
|
|
48
|
-
"@testing-library/react": "^16.1.0",
|
|
46
|
+
"@almadar/eslint-plugin": "^2.18.0",
|
|
49
47
|
"@testing-library/jest-dom": "^6.6.3",
|
|
48
|
+
"@testing-library/react": "^16.1.0",
|
|
50
49
|
"@types/react": "^19.0.0",
|
|
51
50
|
"@types/react-dom": "^19.0.0",
|
|
51
|
+
"@typescript-eslint/parser": "8.56.0",
|
|
52
52
|
"@vitejs/plugin-react": "^4.3.4",
|
|
53
53
|
"autoprefixer": "^10.4.20",
|
|
54
|
+
"eslint": "10.0.0",
|
|
54
55
|
"jsdom": "^25.0.1",
|
|
55
56
|
"postcss": "^8.5.3",
|
|
56
57
|
"tailwindcss": "^3.4.17",
|
|
58
|
+
"@tailwindcss/container-queries": "^0.1.1",
|
|
57
59
|
"typescript": "^5.7.3",
|
|
58
60
|
"vite": "^6.2.1",
|
|
59
61
|
"vitest": "^2.1.9"
|
|
@@ -11,14 +11,19 @@
|
|
|
11
11
|
* - react-router is optional for URL bookmarkability
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import React from 'react';
|
|
14
15
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
|
15
16
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
16
17
|
import { ThemeProvider, UISlotProvider } from '@almadar/ui/context';
|
|
17
|
-
import { UISlotComponent
|
|
18
|
+
import { UISlotComponent } from '@almadar/ui/components';
|
|
18
19
|
import {
|
|
19
20
|
EventBusProvider,
|
|
21
|
+
UserProvider,
|
|
20
22
|
VerificationProvider,
|
|
21
23
|
} from '@almadar/ui/providers';
|
|
24
|
+
import { normalizeUserContext } from '@almadar/core';
|
|
25
|
+
import { AuthProvider, useAuthContext } from './features/auth';
|
|
26
|
+
import { PersonaSwitcher } from './features/auth/components/PersonaSwitcher';
|
|
22
27
|
import { NavigationProvider } from '@almadar/ui/renderer';
|
|
23
28
|
import { I18nProvider, createTranslate } from '@almadar/ui/hooks';
|
|
24
29
|
import defaultLocale from '@almadar/ui/locales/en.json';
|
|
@@ -43,11 +48,25 @@ const queryClient = new QueryClient({
|
|
|
43
48
|
},
|
|
44
49
|
});
|
|
45
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Bridges the signed-in viewer into `UserProvider`. Generated trait hooks read
|
|
53
|
+
* `@user.x` through `useUser()`, so without this every role gate takes its
|
|
54
|
+
* negative branch and every "only mine" list renders empty — with no error, since
|
|
55
|
+
* `useUser()` falls back to anonymous. `normalizeUserContext` maps the provider's
|
|
56
|
+
* `uid`/`displayName` onto the `id`/`name` the behaviors read.
|
|
57
|
+
*/
|
|
58
|
+
function ViewerProvider({ children }: { children: React.ReactNode }) {
|
|
59
|
+
const { user } = useAuthContext();
|
|
60
|
+
return <UserProvider user={normalizeUserContext(user) ?? null}>{children}</UserProvider>;
|
|
61
|
+
}
|
|
62
|
+
|
|
46
63
|
function App() {
|
|
47
64
|
return (
|
|
48
65
|
<I18nProvider value={i18nValue}>
|
|
49
66
|
<QueryClientProvider client={queryClient}>
|
|
50
|
-
<ThemeProvider>
|
|
67
|
+
<ThemeProvider defaultTheme="minimalist">
|
|
68
|
+
<AuthProvider>
|
|
69
|
+
<ViewerProvider>
|
|
51
70
|
<EventBusProvider>
|
|
52
71
|
<VerificationProvider>
|
|
53
72
|
<UISlotProvider>
|
|
@@ -66,12 +85,14 @@ function App() {
|
|
|
66
85
|
{/* Portal slots rendered by compiled trait views via CompiledPortal */}
|
|
67
86
|
{/* Toast notifications (non-overlapping, always safe to render here) */}
|
|
68
87
|
<UISlotComponent slot="toast" portal />
|
|
69
|
-
<
|
|
88
|
+
<PersonaSwitcher />
|
|
70
89
|
</BrowserRouter>
|
|
71
90
|
</NavigationProvider>
|
|
72
91
|
</UISlotProvider>
|
|
73
92
|
</VerificationProvider>
|
|
74
93
|
</EventBusProvider>
|
|
94
|
+
</ViewerProvider>
|
|
95
|
+
</AuthProvider>
|
|
75
96
|
</ThemeProvider>
|
|
76
97
|
</QueryClientProvider>
|
|
77
98
|
</I18nProvider>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { initializeApp, getApps, getApp, FirebaseApp } from 'firebase/app';
|
|
2
2
|
import { getAuth, Auth } from 'firebase/auth';
|
|
3
3
|
|
|
4
|
-
let app: FirebaseApp;
|
|
5
|
-
let auth: Auth;
|
|
4
|
+
let app: FirebaseApp | undefined;
|
|
5
|
+
let auth: Auth | undefined;
|
|
6
6
|
|
|
7
7
|
export async function initializeFirebase(): Promise<void> {
|
|
8
8
|
if (getApps().length > 0) {
|
|
@@ -30,8 +30,29 @@ export async function initializeFirebase(): Promise<void> {
|
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// No credentials anywhere — auth stays disabled instead of crashing
|
|
34
|
+
// the app boot with auth/invalid-api-key.
|
|
35
|
+
if (!config.apiKey) {
|
|
36
|
+
console.warn('Firebase not configured — auth disabled (set VITE_APP_FIREBASE_* env vars)');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
33
40
|
app = initializeApp(config);
|
|
34
41
|
auth = getAuth(app);
|
|
35
42
|
}
|
|
36
43
|
|
|
44
|
+
export function requireAuth(): Auth {
|
|
45
|
+
if (!auth) {
|
|
46
|
+
throw new Error('Firebase auth is not configured — set VITE_APP_FIREBASE_* env vars');
|
|
47
|
+
}
|
|
48
|
+
return auth;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// True once Firebase initialized with real credentials. When false the
|
|
52
|
+
// app runs in auth-disabled mode: route guards pass through and sign-in
|
|
53
|
+
// actions report the missing config instead of crashing the boot.
|
|
54
|
+
export function isAuthEnabled(): boolean {
|
|
55
|
+
return auth !== undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
37
58
|
export { auth, app };
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock auth — a working sign-up / sign-in when Firebase is not configured.
|
|
3
|
+
*
|
|
4
|
+
* Without credentials `requireAuth()` throws and every sign-in reports "not
|
|
5
|
+
* configured", so a generated app cannot be signed into at all. That matters
|
|
6
|
+
* beyond convenience: `@user.id` and `@user.role` are what ownership scoping and
|
|
7
|
+
* role gates resolve against, so with no viewer every "only mine" list is empty
|
|
8
|
+
* and every role gate takes its negative branch — the app can only be seen as
|
|
9
|
+
* nobody. Bypassed the moment real Firebase credentials are present.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_VIEWER,
|
|
14
|
+
encodeDevIdentityToken,
|
|
15
|
+
findPersonaInRoster,
|
|
16
|
+
isFieldValue,
|
|
17
|
+
type FieldValue,
|
|
18
|
+
type UserContext,
|
|
19
|
+
} from '@almadar/core';
|
|
20
|
+
|
|
21
|
+
export interface MockUser {
|
|
22
|
+
uid: string;
|
|
23
|
+
email: string | null;
|
|
24
|
+
displayName: string | null;
|
|
25
|
+
photoURL: string | null;
|
|
26
|
+
role?: string;
|
|
27
|
+
getIdToken(): Promise<string>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Listener = (user: MockUser | null) => void;
|
|
31
|
+
|
|
32
|
+
const STORAGE_KEY = 'almadar.mockAuth.session';
|
|
33
|
+
const REGISTERED_KEY = 'almadar.mockAuth.registered';
|
|
34
|
+
|
|
35
|
+
/** The role a brand-new or unknown account gets — never `admin`. */
|
|
36
|
+
const DEFAULT_ROLE = 'member';
|
|
37
|
+
|
|
38
|
+
/** Empty in dev (the Vite proxy forwards /api); the full server URL in prod. */
|
|
39
|
+
const API_BASE: string =
|
|
40
|
+
typeof import.meta.env.VITE_API_URL === 'string' ? import.meta.env.VITE_API_URL : '';
|
|
41
|
+
|
|
42
|
+
const listeners = new Set<Listener>();
|
|
43
|
+
const rosterListeners = new Set<() => void>();
|
|
44
|
+
let current: MockUser | null = null;
|
|
45
|
+
let registered: UserContext[] = [];
|
|
46
|
+
let roster: UserContext[] = [];
|
|
47
|
+
|
|
48
|
+
function toMockUser(p: UserContext): MockUser {
|
|
49
|
+
return {
|
|
50
|
+
uid: p.id,
|
|
51
|
+
email: typeof p.email === 'string' ? p.email : null,
|
|
52
|
+
displayName: typeof p.name === 'string' ? p.name : null,
|
|
53
|
+
photoURL: null,
|
|
54
|
+
role: typeof p.role === 'string' ? p.role : undefined,
|
|
55
|
+
// The server trusts this only under ALLOW_DEV_AUTH_BYPASS; it carries the
|
|
56
|
+
// whole persona so server-side `@user.role` matches the client's.
|
|
57
|
+
getIdToken: () => Promise.resolve(encodeDevIdentityToken(p)),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A stored account, validated field by field — never trust localStorage shape. */
|
|
62
|
+
function readPersona(raw: FieldValue | undefined): UserContext | null {
|
|
63
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw) || raw instanceof Date) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const record: { [key: string]: FieldValue | undefined } = { ...raw };
|
|
67
|
+
const id = record['id'];
|
|
68
|
+
if (typeof id !== 'string' || id.length === 0) return null;
|
|
69
|
+
const persona: UserContext = { id };
|
|
70
|
+
for (const key of ['email', 'name', 'role'] as const) {
|
|
71
|
+
const value = record[key];
|
|
72
|
+
if (typeof value === 'string' && isFieldValue(value)) persona[key] = value;
|
|
73
|
+
}
|
|
74
|
+
return persona;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readRegistered(): UserContext[] {
|
|
78
|
+
try {
|
|
79
|
+
const raw = localStorage.getItem(REGISTERED_KEY);
|
|
80
|
+
if (!raw) return [];
|
|
81
|
+
const parsed: FieldValue = JSON.parse(raw);
|
|
82
|
+
if (!Array.isArray(parsed)) return [];
|
|
83
|
+
return parsed.map(readPersona).filter((p): p is UserContext => p !== null);
|
|
84
|
+
} catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function writeRegistered(list: UserContext[]): void {
|
|
90
|
+
registered = list;
|
|
91
|
+
try {
|
|
92
|
+
localStorage.setItem(REGISTERED_KEY, JSON.stringify(list));
|
|
93
|
+
} catch {
|
|
94
|
+
/* storage unavailable — the session stays in memory for this tab */
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The app's personas, fetched from its own server.
|
|
100
|
+
*
|
|
101
|
+
* They are the LIVE seeded rows of the app's `[identity]` entity, not a
|
|
102
|
+
* hardcoded list and not a re-derivation: `@user.id` is what ownership scoping
|
|
103
|
+
* compares against, so a persona whose id is not literally one of those rows
|
|
104
|
+
* owns nothing and every "only mine" list renders empty — indistinguishable
|
|
105
|
+
* from a working filter over no data.
|
|
106
|
+
*
|
|
107
|
+
* An app that declares no `[identity]` entity has no roster to serve, so the
|
|
108
|
+
* shell's own default viewer stands in and sign-in still works.
|
|
109
|
+
*/
|
|
110
|
+
async function loadRoster(): Promise<void> {
|
|
111
|
+
try {
|
|
112
|
+
const response = await fetch(`${API_BASE}/api/personas`);
|
|
113
|
+
if (response.ok) {
|
|
114
|
+
const data = (await response.json()) as { personas?: UserContext[] };
|
|
115
|
+
roster = Array.isArray(data.personas) ? data.personas : [];
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
/* server unreachable — fall through to the default viewer */
|
|
119
|
+
}
|
|
120
|
+
if (roster.length === 0) roster = [DEFAULT_VIEWER];
|
|
121
|
+
for (const fn of rosterListeners) fn();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function allAccounts(): UserContext[] {
|
|
125
|
+
return [...roster, ...registered];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function emit(): void {
|
|
129
|
+
for (const fn of listeners) fn(current);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function persist(user: MockUser | null): void {
|
|
133
|
+
try {
|
|
134
|
+
if (user) localStorage.setItem(STORAGE_KEY, JSON.stringify({ uid: user.uid }));
|
|
135
|
+
else localStorage.removeItem(STORAGE_KEY);
|
|
136
|
+
} catch {
|
|
137
|
+
/* storage unavailable */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Load the app's roster, then restore a persisted session. Safe to call more
|
|
143
|
+
* than once.
|
|
144
|
+
*
|
|
145
|
+
* The roster is awaited first because a restored session names a persona by id:
|
|
146
|
+
* resolved before the rows arrive, every stored session would silently fail to
|
|
147
|
+
* restore and the app would open signed out.
|
|
148
|
+
*/
|
|
149
|
+
export async function initMockAuth(): Promise<void> {
|
|
150
|
+
registered = readRegistered();
|
|
151
|
+
await loadRoster();
|
|
152
|
+
try {
|
|
153
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
154
|
+
if (!raw) return;
|
|
155
|
+
const parsed: FieldValue = JSON.parse(raw);
|
|
156
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return;
|
|
157
|
+
if (parsed instanceof Date) return;
|
|
158
|
+
const session: { [key: string]: FieldValue | undefined } = { ...parsed };
|
|
159
|
+
const uid = session['uid'];
|
|
160
|
+
if (typeof uid !== 'string') return;
|
|
161
|
+
const found = allAccounts().find((a) => a.id === uid);
|
|
162
|
+
if (found) {
|
|
163
|
+
current = toMockUser(found);
|
|
164
|
+
emit();
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
/* ignore a corrupt session */
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Every persona that can be signed into — for the dev persona picker. */
|
|
172
|
+
export function listMockAccounts(): readonly UserContext[] {
|
|
173
|
+
return allAccounts();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Subscribe to roster arrival. The roster loads over HTTP after first paint, so
|
|
178
|
+
* the persona picker renders empty and must be told when the rows land — the
|
|
179
|
+
* auth listener cannot carry it, since the signed-out viewer does not change.
|
|
180
|
+
*/
|
|
181
|
+
export function onMockRosterChanged(fn: () => void): () => void {
|
|
182
|
+
rosterListeners.add(fn);
|
|
183
|
+
return () => {
|
|
184
|
+
rosterListeners.delete(fn);
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function newAccount(email: string, displayName?: string): UserContext {
|
|
189
|
+
return {
|
|
190
|
+
id: `user-${allAccounts().length + 1}`,
|
|
191
|
+
email,
|
|
192
|
+
name: displayName ?? email.split('@')[0] ?? email,
|
|
193
|
+
role: DEFAULT_ROLE,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function byEmail(email: string): UserContext | undefined {
|
|
198
|
+
return allAccounts().find(
|
|
199
|
+
(a) => typeof a.email === 'string' && a.email.toLowerCase() === email.toLowerCase(),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function signInAs(account: UserContext): MockUser {
|
|
204
|
+
current = toMockUser(account);
|
|
205
|
+
persist(current);
|
|
206
|
+
emit();
|
|
207
|
+
return current;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export const mockAuth = {
|
|
211
|
+
get currentUser(): MockUser | null {
|
|
212
|
+
return current;
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
onAuthStateChanged(fn: Listener): () => void {
|
|
216
|
+
listeners.add(fn);
|
|
217
|
+
// Match Firebase: the listener fires immediately with the current state.
|
|
218
|
+
fn(current);
|
|
219
|
+
return () => listeners.delete(fn);
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
/** Any password is accepted; an unknown email signs in as a new end-user. */
|
|
223
|
+
signInWithEmail(email: string, _password: string): Promise<MockUser> {
|
|
224
|
+
return Promise.resolve(signInAs(byEmail(email) ?? newAccount(email)));
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
signUpWithEmail(email: string, _password: string, displayName?: string): Promise<MockUser> {
|
|
228
|
+
if (byEmail(email)) {
|
|
229
|
+
return Promise.reject(new Error('An account with that email already exists'));
|
|
230
|
+
}
|
|
231
|
+
const account = newAccount(email, displayName);
|
|
232
|
+
writeRegistered([...registered, account]);
|
|
233
|
+
return Promise.resolve(signInAs(account));
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
/** Sign in directly as a named persona — the dev persona switch. */
|
|
237
|
+
signInAsPersona(idOrRole: string): Promise<MockUser> {
|
|
238
|
+
const account =
|
|
239
|
+
findPersonaInRoster(roster, idOrRole) ??
|
|
240
|
+
registered.find((a) => a.id === idOrRole || a.role === idOrRole);
|
|
241
|
+
if (!account) {
|
|
242
|
+
return Promise.reject(
|
|
243
|
+
new Error(
|
|
244
|
+
`Unknown persona "${idOrRole}". Known: ${allAccounts().map((a) => `${a.id}/${String(a.role)}`).join(', ')}`,
|
|
245
|
+
),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
return Promise.resolve(signInAs(account));
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
signOut(): Promise<void> {
|
|
252
|
+
current = null;
|
|
253
|
+
persist(null);
|
|
254
|
+
emit();
|
|
255
|
+
return Promise.resolve();
|
|
256
|
+
},
|
|
257
|
+
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import React, { createContext, useContext, useEffect, useState } from 'react';
|
|
2
|
-
import { User } from 'firebase/auth';
|
|
3
2
|
import { auth, initializeFirebase } from '../../config/firebase';
|
|
3
|
+
import { initMockAuth, mockAuth } from '../../config/mockAuth';
|
|
4
4
|
import { authService } from './authService';
|
|
5
|
-
import { AuthContextType } from './types';
|
|
5
|
+
import { AuthContextType, AuthViewer } from './types';
|
|
6
6
|
|
|
7
7
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|
8
8
|
|
|
@@ -19,7 +19,7 @@ interface AuthProviderProps {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|
22
|
-
const [user, setUser] = useState<
|
|
22
|
+
const [user, setUser] = useState<AuthViewer | null>(null);
|
|
23
23
|
const [loading, setLoading] = useState(true);
|
|
24
24
|
const [error, setError] = useState<string | null>(null);
|
|
25
25
|
|
|
@@ -27,6 +27,19 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|
|
27
27
|
let unsubscribe: (() => void) | undefined;
|
|
28
28
|
|
|
29
29
|
initializeFirebase().then(() => {
|
|
30
|
+
// No Firebase config — subscribe to the persona-backed mock instead of
|
|
31
|
+
// rendering signed-out forever, so the app can be viewed as each persona.
|
|
32
|
+
if (!auth) {
|
|
33
|
+
// Subscribe first: the listener fires immediately with the current
|
|
34
|
+
// (signed-out) state so the app paints, then again once the roster has
|
|
35
|
+
// arrived and a persisted session is restored.
|
|
36
|
+
unsubscribe = mockAuth.onAuthStateChanged((mockUser) => {
|
|
37
|
+
setUser(mockUser);
|
|
38
|
+
setLoading(false);
|
|
39
|
+
});
|
|
40
|
+
void initMockAuth();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
30
43
|
unsubscribe = auth.onAuthStateChanged((firebaseUser) => {
|
|
31
44
|
setUser(firebaseUser);
|
|
32
45
|
setLoading(false);
|
|
@@ -43,7 +56,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|
|
43
56
|
setLoading(true);
|
|
44
57
|
clearError();
|
|
45
58
|
await authService.signInWithGoogle();
|
|
46
|
-
} catch (err
|
|
59
|
+
} catch (err) {
|
|
47
60
|
setLoading(false);
|
|
48
61
|
const firebaseErr = err as { code?: string; message?: string };
|
|
49
62
|
const isCancel =
|
|
@@ -61,7 +74,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|
|
61
74
|
setLoading(true);
|
|
62
75
|
clearError();
|
|
63
76
|
await authService.signInWithEmail(email, password);
|
|
64
|
-
} catch (err
|
|
77
|
+
} catch (err) {
|
|
65
78
|
setLoading(false);
|
|
66
79
|
setError((err as { message?: string }).message ?? 'Sign-in failed');
|
|
67
80
|
}
|
|
@@ -72,7 +85,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|
|
72
85
|
setLoading(true);
|
|
73
86
|
clearError();
|
|
74
87
|
await authService.signUpWithEmail(email, password, displayName);
|
|
75
|
-
} catch (err
|
|
88
|
+
} catch (err) {
|
|
76
89
|
setLoading(false);
|
|
77
90
|
setError((err as { message?: string }).message ?? 'Sign-up failed');
|
|
78
91
|
}
|
|
@@ -88,7 +101,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|
|
88
101
|
};
|
|
89
102
|
await authService.sendSignInLinkToEmail(email, actionCodeSettings);
|
|
90
103
|
setLoading(false);
|
|
91
|
-
} catch (err
|
|
104
|
+
} catch (err) {
|
|
92
105
|
setLoading(false);
|
|
93
106
|
setError((err as { message?: string }).message ?? 'Failed to send sign-in link');
|
|
94
107
|
}
|
|
@@ -99,7 +112,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|
|
99
112
|
setLoading(true);
|
|
100
113
|
clearError();
|
|
101
114
|
await authService.signInWithEmailLink(email, emailLink);
|
|
102
|
-
} catch (err
|
|
115
|
+
} catch (err) {
|
|
103
116
|
setLoading(false);
|
|
104
117
|
setError((err as { message?: string }).message ?? 'Email link sign-in failed');
|
|
105
118
|
}
|
|
@@ -115,7 +128,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|
|
115
128
|
await authService.signOut();
|
|
116
129
|
setUser(null);
|
|
117
130
|
setLoading(false);
|
|
118
|
-
} catch (err
|
|
131
|
+
} catch (err) {
|
|
119
132
|
setLoading(false);
|
|
120
133
|
setError((err as { message?: string }).message ?? 'Sign-out failed');
|
|
121
134
|
}
|