@almadar/orb 16.9.1 → 17.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/package.json +9 -6
- package/scripts/postinstall.js +38 -0
- package/shells/almadar-shell/package.json +17 -3
- package/shells/almadar-shell/packages/client/package.json +7 -7
- package/shells/almadar-shell/packages/client/src/App.tsx +23 -2
- 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/server/package.json +9 -7
- 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 +2 -8
- 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 +3963 -1119
- 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/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 +4370 -1238
package/README.md
CHANGED
|
@@ -14,6 +14,11 @@ Or use the curl installer:
|
|
|
14
14
|
curl -fsSL https://orb.almadar.io/install.sh | sh
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
The installer populates the user store (`~/.orb`) with `@almadar/std` so `orb validate`/`orb verify`
|
|
18
|
+
resolve std behaviors right away; a project can pin its own std instead with
|
|
19
|
+
`orb behaviors install @almadar/std`. Offline, install from a tarball:
|
|
20
|
+
`orb behaviors install --global ./almadar-std-<v>.tgz`.
|
|
21
|
+
|
|
17
22
|
## Usage
|
|
18
23
|
|
|
19
24
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@almadar/orb",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "17.0.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,13 @@
|
|
|
49
49
|
"node": ">=16.0.0"
|
|
50
50
|
},
|
|
51
51
|
"optionalDependencies": {
|
|
52
|
-
"@almadar/orb-darwin-x64": "
|
|
53
|
-
"@almadar/orb-darwin-arm64": "
|
|
54
|
-
"@almadar/orb-linux-x64": "
|
|
55
|
-
"@almadar/orb-linux-arm64": "
|
|
56
|
-
"@almadar/orb-windows-x64": "
|
|
52
|
+
"@almadar/orb-darwin-x64": "17.0.0",
|
|
53
|
+
"@almadar/orb-darwin-arm64": "17.0.0",
|
|
54
|
+
"@almadar/orb-linux-x64": "17.0.0",
|
|
55
|
+
"@almadar/orb-linux-arm64": "17.0.0",
|
|
56
|
+
"@almadar/orb-windows-x64": "17.0.0"
|
|
57
|
+
},
|
|
58
|
+
"almadar": {
|
|
59
|
+
"stdRange": "^16"
|
|
57
60
|
}
|
|
58
61
|
}
|
package/scripts/postinstall.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
5
6
|
|
|
6
7
|
const platform = process.platform;
|
|
7
8
|
const arch = process.arch;
|
|
@@ -49,3 +50,40 @@ console.log(`Orb CLI installed for ${platform}-${arch}:`);
|
|
|
49
50
|
console.log(` Binary: ${hasBinary ? 'ok' : 'missing'}`);
|
|
50
51
|
console.log(` Bun: ${hasBun ? 'ok' : 'not bundled (agent features require bun on PATH)'}`);
|
|
51
52
|
console.log(` Agent: ${hasAgent ? 'ok' : 'not bundled (agent features unavailable)'}`);
|
|
53
|
+
|
|
54
|
+
// Behaviors are installed packages, not baked into the binary (Phase 2): populate
|
|
55
|
+
// the user store (~/.orb) with @almadar/std so `orb validate`/`orb verify` resolve
|
|
56
|
+
// std behaviors out of the box. `stdRange` is `^<major>` of the std this release
|
|
57
|
+
// was built against (almadar.stdRange in package.json, written by build-orb-cli.yml
|
|
58
|
+
// from the orbital-rust pin) so a later std major never silently swaps underneath
|
|
59
|
+
// an already-installed CLI.
|
|
60
|
+
const stdRange = (require('../package.json').almadar || {}).stdRange;
|
|
61
|
+
|
|
62
|
+
if (hasBinary && hasBun && stdRange) {
|
|
63
|
+
const binaryPath = path.join(platformDir, binaryName);
|
|
64
|
+
const bunPath = path.join(platformDir, bunName);
|
|
65
|
+
const manualCommand = 'orb behaviors install --global @almadar/std';
|
|
66
|
+
|
|
67
|
+
let result;
|
|
68
|
+
try {
|
|
69
|
+
result = spawnSync(binaryPath, ['behaviors', 'install', '--global', `@almadar/std@${stdRange}`], {
|
|
70
|
+
env: { ...process.env, ORB_BUN_PATH: bunPath },
|
|
71
|
+
stdio: 'inherit',
|
|
72
|
+
timeout: 600000,
|
|
73
|
+
});
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.warn(`\n Orb CLI: failed to install @almadar/std (${err.message})`);
|
|
76
|
+
console.warn(` Run manually: ${manualCommand}\n`);
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!result || result.status !== 0) {
|
|
81
|
+
console.warn('\n Orb CLI: @almadar/std install did not complete');
|
|
82
|
+
console.warn(` Run manually: ${manualCommand}\n`);
|
|
83
|
+
}
|
|
84
|
+
} else if (hasBinary && !hasBun) {
|
|
85
|
+
console.warn('\n Orb CLI: bun not bundled, skipping @almadar/std install');
|
|
86
|
+
console.warn(' Run manually once bun is available: orb behaviors install --global @almadar/std\n');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
process.exit(0);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@almadar/shell",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.115.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",
|
|
@@ -13,12 +13,12 @@
|
|
|
13
13
|
"test:watch": "vitest"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@almadar/core": "^
|
|
17
|
-
"@almadar/evaluator": "^2.
|
|
18
|
-
"@almadar/logger": "^1.
|
|
19
|
-
"@almadar/patterns": "^2.
|
|
20
|
-
"@almadar/syntax": "^1.
|
|
21
|
-
"@almadar/ui": "^
|
|
16
|
+
"@almadar/core": "^10.99.0",
|
|
17
|
+
"@almadar/evaluator": "^2.47.0",
|
|
18
|
+
"@almadar/logger": "^1.12.0",
|
|
19
|
+
"@almadar/patterns": "^2.101.0",
|
|
20
|
+
"@almadar/syntax": "^1.17.0",
|
|
21
|
+
"@almadar/ui": "^6.31.0",
|
|
22
22
|
"@monaco-editor/react": "^4.7.0",
|
|
23
23
|
"@react-three/drei": "^9.92.0",
|
|
24
24
|
"@react-three/fiber": "^9.0.0",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"zustand": "^5.0.3"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
|
-
"@almadar/eslint-plugin": "^2.
|
|
46
|
+
"@almadar/eslint-plugin": "^2.18.0",
|
|
47
47
|
"@testing-library/jest-dom": "^6.6.3",
|
|
48
48
|
"@testing-library/react": "^16.1.0",
|
|
49
49
|
"@types/react": "^19.0.0",
|
|
@@ -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
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
|
}
|