@almadar/orb 16.9.0 → 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/LICENSE +72 -0
- package/shells/almadar-shell/README.md +25 -0
- package/shells/almadar-shell/locales/en.json +120 -0
- package/shells/almadar-shell/package.json +49 -0
- package/shells/almadar-shell/packages/client/eslint.config.cjs +56 -0
- package/shells/almadar-shell/packages/client/index.html +13 -0
- package/shells/almadar-shell/packages/client/package.json +63 -0
- package/shells/almadar-shell/packages/client/postcss.config.js +6 -0
- package/shells/almadar-shell/packages/client/src/App.tsx +102 -0
- package/shells/almadar-shell/packages/client/src/config/firebase.ts +58 -0
- package/shells/almadar-shell/packages/client/src/config/mockAuth.ts +257 -0
- package/shells/almadar-shell/packages/client/src/features/auth/AuthContext.tsx +152 -0
- package/shells/almadar-shell/packages/client/src/features/auth/authService.ts +104 -0
- package/shells/almadar-shell/packages/client/src/features/auth/components/Login.tsx +218 -0
- 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 +36 -0
- package/shells/almadar-shell/packages/client/src/features/auth/components/UserProfile.tsx +68 -0
- package/shells/almadar-shell/packages/client/src/features/auth/components/index.ts +3 -0
- package/shells/almadar-shell/packages/client/src/features/auth/index.ts +14 -0
- package/shells/almadar-shell/packages/client/src/features/auth/types.ts +40 -0
- package/shells/almadar-shell/packages/client/src/index.css +19 -0
- package/shells/almadar-shell/packages/client/src/main.tsx +8 -0
- package/shells/almadar-shell/packages/client/src/navigation/index.ts +55 -0
- package/shells/almadar-shell/packages/client/src/pages/index.ts +12 -0
- package/shells/almadar-shell/packages/client/tailwind-preset.cjs +259 -0
- package/shells/almadar-shell/packages/client/tailwind.config.js +31 -0
- package/shells/almadar-shell/packages/client/tsconfig.json +33 -0
- package/shells/almadar-shell/packages/client/vite.config.ts +50 -0
- package/shells/almadar-shell/packages/server/eslint.config.cjs +23 -0
- package/shells/almadar-shell/packages/server/package.json +42 -0
- package/shells/almadar-shell/packages/server/pnpm-lock.yaml +4723 -0
- package/shells/almadar-shell/packages/server/src/app.ts +91 -0
- package/shells/almadar-shell/packages/server/src/hooks-providers.ts +11 -0
- package/shells/almadar-shell/packages/server/src/index.ts +69 -0
- package/shells/almadar-shell/packages/server/src/routes.ts +11 -0
- 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 +11 -0
- package/shells/almadar-shell/packages/server/tsconfig.json +22 -0
- package/shells/almadar-shell/packages/shared/package.json +11 -0
- package/shells/almadar-shell/packages/shared/pnpm-lock.yaml +22 -0
- package/shells/almadar-shell/packages/shared/src/index.ts +2 -0
- package/shells/almadar-shell/pnpm-lock.yaml +11699 -0
- package/shells/almadar-shell/pnpm-workspace.yaml +2 -0
- package/shells/almadar-shell/tsup.config.ts +13 -0
- package/shells/almadar-shell/turbo.json +17 -0
- package/shells/almadar-shell/vitest.config.ts +8 -0
- package/shells/almadar-shell-hono/LICENSE +72 -0
- package/shells/almadar-shell-hono/README.md +25 -0
- package/shells/almadar-shell-hono/locales/en.json +120 -0
- package/shells/almadar-shell-hono/package.json +37 -0
- package/shells/almadar-shell-hono/packages/client/eslint.config.cjs +23 -0
- package/shells/almadar-shell-hono/packages/client/index.html +13 -0
- package/shells/almadar-shell-hono/packages/client/package-lock.json +9750 -0
- package/shells/almadar-shell-hono/packages/client/package.json +61 -0
- package/shells/almadar-shell-hono/packages/client/postcss.config.js +6 -0
- package/shells/almadar-shell-hono/packages/client/src/App.tsx +80 -0
- package/shells/almadar-shell-hono/packages/client/src/config/firebase.ts +58 -0
- package/shells/almadar-shell-hono/packages/client/src/features/auth/AuthContext.tsx +144 -0
- package/shells/almadar-shell-hono/packages/client/src/features/auth/authService.ts +86 -0
- package/shells/almadar-shell-hono/packages/client/src/features/auth/components/Login.tsx +218 -0
- package/shells/almadar-shell-hono/packages/client/src/features/auth/components/ProtectedRoute.tsx +36 -0
- package/shells/almadar-shell-hono/packages/client/src/features/auth/components/UserProfile.tsx +68 -0
- package/shells/almadar-shell-hono/packages/client/src/features/auth/components/index.ts +3 -0
- package/shells/almadar-shell-hono/packages/client/src/features/auth/index.ts +13 -0
- package/shells/almadar-shell-hono/packages/client/src/features/auth/types.ts +24 -0
- package/shells/almadar-shell-hono/packages/client/src/index.css +35 -0
- package/shells/almadar-shell-hono/packages/client/src/main.tsx +8 -0
- package/shells/almadar-shell-hono/packages/client/src/navigation/index.ts +55 -0
- package/shells/almadar-shell-hono/packages/client/src/pages/index.ts +12 -0
- package/shells/almadar-shell-hono/packages/client/tailwind-preset.cjs +259 -0
- package/shells/almadar-shell-hono/packages/client/tailwind.config.js +24 -0
- package/shells/almadar-shell-hono/packages/client/tsconfig.json +33 -0
- package/shells/almadar-shell-hono/packages/client/vite.config.ts +50 -0
- package/shells/almadar-shell-hono/packages/server/eslint.config.cjs +19 -0
- package/shells/almadar-shell-hono/packages/server/package.json +38 -0
- package/shells/almadar-shell-hono/packages/server/pnpm-lock.yaml +4665 -0
- package/shells/almadar-shell-hono/packages/server/src/app.ts +70 -0
- package/shells/almadar-shell-hono/packages/server/src/hooks-providers.ts +11 -0
- package/shells/almadar-shell-hono/packages/server/src/index.ts +70 -0
- package/shells/almadar-shell-hono/packages/server/src/routes.ts +12 -0
- package/shells/almadar-shell-hono/packages/server/src/serve.ts +47 -0
- 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 +22 -0
- package/shells/almadar-shell-hono/packages/shared/package.json +25 -0
- package/shells/almadar-shell-hono/packages/shared/pnpm-lock.yaml +919 -0
- package/shells/almadar-shell-hono/packages/shared/src/index.ts +2 -0
- package/shells/almadar-shell-hono/packages/shared/tsconfig.json +17 -0
- package/shells/almadar-shell-hono/packages/shared/tsup.config.ts +10 -0
- package/shells/almadar-shell-hono/pnpm-lock.yaml +12573 -0
- package/shells/almadar-shell-hono/pnpm-workspace.yaml +2 -0
- package/shells/almadar-shell-hono/tsup.config.ts +13 -0
- package/shells/almadar-shell-hono/turbo.json +17 -0
- package/shells/almadar-shell-hono/vitest.config.ts +8 -0
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import React, { createContext, useContext, useEffect, useState } from 'react';
|
|
2
|
+
import { auth, initializeFirebase } from '../../config/firebase';
|
|
3
|
+
import { initMockAuth, mockAuth } from '../../config/mockAuth';
|
|
4
|
+
import { authService } from './authService';
|
|
5
|
+
import { AuthContextType, AuthViewer } from './types';
|
|
6
|
+
|
|
7
|
+
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|
8
|
+
|
|
9
|
+
export const useAuthContext = () => {
|
|
10
|
+
const context = useContext(AuthContext);
|
|
11
|
+
if (context === undefined) {
|
|
12
|
+
throw new Error('useAuthContext must be used within an AuthProvider');
|
|
13
|
+
}
|
|
14
|
+
return context;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
interface AuthProviderProps {
|
|
18
|
+
children: React.ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|
22
|
+
const [user, setUser] = useState<AuthViewer | null>(null);
|
|
23
|
+
const [loading, setLoading] = useState(true);
|
|
24
|
+
const [error, setError] = useState<string | null>(null);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
let unsubscribe: (() => void) | undefined;
|
|
28
|
+
|
|
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
|
+
}
|
|
43
|
+
unsubscribe = auth.onAuthStateChanged((firebaseUser) => {
|
|
44
|
+
setUser(firebaseUser);
|
|
45
|
+
setLoading(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return () => unsubscribe?.();
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const clearError = () => setError(null);
|
|
53
|
+
|
|
54
|
+
const signInWithGoogle = async () => {
|
|
55
|
+
try {
|
|
56
|
+
setLoading(true);
|
|
57
|
+
clearError();
|
|
58
|
+
await authService.signInWithGoogle();
|
|
59
|
+
} catch (err) {
|
|
60
|
+
setLoading(false);
|
|
61
|
+
const firebaseErr = err as { code?: string; message?: string };
|
|
62
|
+
const isCancel =
|
|
63
|
+
firebaseErr.code === 'auth/popup-closed-by-user' ||
|
|
64
|
+
firebaseErr.code === 'auth/cancelled-popup-request' ||
|
|
65
|
+
firebaseErr.code === 'auth/popup-blocked';
|
|
66
|
+
if (!isCancel) {
|
|
67
|
+
setError(firebaseErr.message ?? 'Google sign-in failed');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const signInWithEmail = async (email: string, password: string) => {
|
|
73
|
+
try {
|
|
74
|
+
setLoading(true);
|
|
75
|
+
clearError();
|
|
76
|
+
await authService.signInWithEmail(email, password);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
setLoading(false);
|
|
79
|
+
setError((err as { message?: string }).message ?? 'Sign-in failed');
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const signUpWithEmail = async (email: string, password: string, displayName?: string) => {
|
|
84
|
+
try {
|
|
85
|
+
setLoading(true);
|
|
86
|
+
clearError();
|
|
87
|
+
await authService.signUpWithEmail(email, password, displayName);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
setLoading(false);
|
|
90
|
+
setError((err as { message?: string }).message ?? 'Sign-up failed');
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const sendSignInLinkToEmail = async (email: string) => {
|
|
95
|
+
try {
|
|
96
|
+
setLoading(true);
|
|
97
|
+
clearError();
|
|
98
|
+
const actionCodeSettings = {
|
|
99
|
+
url: `${window.location.origin}/login`,
|
|
100
|
+
handleCodeInApp: true,
|
|
101
|
+
};
|
|
102
|
+
await authService.sendSignInLinkToEmail(email, actionCodeSettings);
|
|
103
|
+
setLoading(false);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
setLoading(false);
|
|
106
|
+
setError((err as { message?: string }).message ?? 'Failed to send sign-in link');
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const signInWithEmailLink = async (email: string, emailLink: string) => {
|
|
111
|
+
try {
|
|
112
|
+
setLoading(true);
|
|
113
|
+
clearError();
|
|
114
|
+
await authService.signInWithEmailLink(email, emailLink);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
setLoading(false);
|
|
117
|
+
setError((err as { message?: string }).message ?? 'Email link sign-in failed');
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const isSignInWithEmailLink = (emailLink: string): boolean => {
|
|
122
|
+
return authService.isSignInWithEmailLink(emailLink);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const signOut = async () => {
|
|
126
|
+
try {
|
|
127
|
+
setLoading(true);
|
|
128
|
+
await authService.signOut();
|
|
129
|
+
setUser(null);
|
|
130
|
+
setLoading(false);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
setLoading(false);
|
|
133
|
+
setError((err as { message?: string }).message ?? 'Sign-out failed');
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const value: AuthContextType = {
|
|
138
|
+
user,
|
|
139
|
+
loading,
|
|
140
|
+
error,
|
|
141
|
+
signInWithGoogle,
|
|
142
|
+
signOut,
|
|
143
|
+
signInWithEmail,
|
|
144
|
+
signUpWithEmail,
|
|
145
|
+
sendSignInLinkToEmail,
|
|
146
|
+
signInWithEmailLink,
|
|
147
|
+
isSignInWithEmailLink,
|
|
148
|
+
clearError,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
152
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import {
|
|
2
|
+
signInWithPopup,
|
|
3
|
+
GoogleAuthProvider,
|
|
4
|
+
signInWithEmailAndPassword,
|
|
5
|
+
createUserWithEmailAndPassword,
|
|
6
|
+
updateProfile,
|
|
7
|
+
signOut as firebaseSignOut,
|
|
8
|
+
sendSignInLinkToEmail as firebaseSendSignInLinkToEmail,
|
|
9
|
+
isSignInWithEmailLink as firebaseIsSignInWithEmailLink,
|
|
10
|
+
signInWithEmailLink as firebaseSignInWithEmailLink,
|
|
11
|
+
ActionCodeSettings,
|
|
12
|
+
} from 'firebase/auth';
|
|
13
|
+
import { auth, isAuthEnabled, requireAuth } from '../../config/firebase';
|
|
14
|
+
import { mockAuth } from '../../config/mockAuth';
|
|
15
|
+
|
|
16
|
+
// With no Firebase credentials the real calls throw "not configured", which
|
|
17
|
+
// leaves a generated app impossible to sign into — and therefore impossible to
|
|
18
|
+
// view as anyone, since @user.id/@user.role drive ownership and role gates.
|
|
19
|
+
// Route to the persona-backed mock in exactly that case.
|
|
20
|
+
const useMock = (): boolean => !isAuthEnabled();
|
|
21
|
+
|
|
22
|
+
const googleProvider = new GoogleAuthProvider();
|
|
23
|
+
|
|
24
|
+
export const authService = {
|
|
25
|
+
// Google Sign In
|
|
26
|
+
signInWithGoogle: async () => {
|
|
27
|
+
// No popup to show without Firebase — sign in as the seeded end-user.
|
|
28
|
+
if (useMock()) return mockAuth.signInAsPersona('member');
|
|
29
|
+
try {
|
|
30
|
+
const result = await signInWithPopup(requireAuth(), googleProvider);
|
|
31
|
+
return result.user;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
// Email/Password Sign In
|
|
38
|
+
signInWithEmail: async (email: string, password: string) => {
|
|
39
|
+
if (useMock()) return mockAuth.signInWithEmail(email, password);
|
|
40
|
+
try {
|
|
41
|
+
const result = await signInWithEmailAndPassword(requireAuth(), email, password);
|
|
42
|
+
return result.user;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
// Email/Password Sign Up
|
|
49
|
+
signUpWithEmail: async (email: string, password: string, displayName?: string) => {
|
|
50
|
+
if (useMock()) return mockAuth.signUpWithEmail(email, password, displayName);
|
|
51
|
+
try {
|
|
52
|
+
const result = await createUserWithEmailAndPassword(requireAuth(), email, password);
|
|
53
|
+
|
|
54
|
+
if (displayName) {
|
|
55
|
+
await updateProfile(result.user, { displayName });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return result.user;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
// Sign Out
|
|
65
|
+
signOut: async () => {
|
|
66
|
+
if (useMock()) return mockAuth.signOut();
|
|
67
|
+
try {
|
|
68
|
+
await firebaseSignOut(requireAuth());
|
|
69
|
+
} catch (error) {
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
// Email Link Authentication
|
|
75
|
+
sendSignInLinkToEmail: async (email: string, actionCodeSettings: ActionCodeSettings) => {
|
|
76
|
+
try {
|
|
77
|
+
await firebaseSendSignInLinkToEmail(requireAuth(), email, actionCodeSettings);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
isSignInWithEmailLink: (emailLink: string): boolean => {
|
|
84
|
+
// Predicate evaluated during Login render — degrade, don't throw,
|
|
85
|
+
// when auth is unconfigured: it simply isn't an email-link sign-in.
|
|
86
|
+
if (!auth) return false;
|
|
87
|
+
return firebaseIsSignInWithEmailLink(auth, emailLink);
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
signInWithEmailLink: async (email: string, emailLink: string) => {
|
|
91
|
+
try {
|
|
92
|
+
const result = await firebaseSignInWithEmailLink(requireAuth(), email, emailLink);
|
|
93
|
+
return result.user;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
/** Sign in as a named seeded persona (dev only; no-op with real Firebase). */
|
|
100
|
+
signInAsPersona: async (idOrRole: string) => {
|
|
101
|
+
if (useMock()) return mockAuth.signInAsPersona(idOrRole);
|
|
102
|
+
throw new Error('Persona sign-in is a dev-only affordance; real auth is configured');
|
|
103
|
+
},
|
|
104
|
+
};
|