@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,218 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { useAuthContext } from '../AuthContext';
|
|
3
|
+
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
4
|
+
|
|
5
|
+
const EMAIL_FOR_SIGN_IN_KEY = 'emailForSignIn';
|
|
6
|
+
|
|
7
|
+
const Login: React.FC = () => {
|
|
8
|
+
const [email, setEmail] = useState('');
|
|
9
|
+
const [password, setPassword] = useState('');
|
|
10
|
+
const [isSignUp, setIsSignUp] = useState(false);
|
|
11
|
+
const [displayName, setDisplayName] = useState('');
|
|
12
|
+
const [completingEmailLink, setCompletingEmailLink] = useState(false);
|
|
13
|
+
const [emailForLinkCompletion, setEmailForLinkCompletion] = useState('');
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
loading,
|
|
17
|
+
error,
|
|
18
|
+
signInWithGoogle,
|
|
19
|
+
signInWithEmail,
|
|
20
|
+
signUpWithEmail,
|
|
21
|
+
signInWithEmailLink,
|
|
22
|
+
isSignInWithEmailLink,
|
|
23
|
+
clearError,
|
|
24
|
+
} = useAuthContext();
|
|
25
|
+
const navigate = useNavigate();
|
|
26
|
+
const [searchParams] = useSearchParams();
|
|
27
|
+
|
|
28
|
+
// Redirect on successful auth
|
|
29
|
+
const { user } = useAuthContext();
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (user) {
|
|
32
|
+
const returnUrl = searchParams.get('returnUrl') || '/';
|
|
33
|
+
navigate(returnUrl, { replace: true });
|
|
34
|
+
}
|
|
35
|
+
}, [user, navigate, searchParams]);
|
|
36
|
+
|
|
37
|
+
// Check if user is returning from email link
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (isSignInWithEmailLink(window.location.href)) {
|
|
40
|
+
const emailForSignIn = window.localStorage.getItem(EMAIL_FOR_SIGN_IN_KEY);
|
|
41
|
+
|
|
42
|
+
if (emailForSignIn) {
|
|
43
|
+
setCompletingEmailLink(true);
|
|
44
|
+
signInWithEmailLink(emailForSignIn, window.location.href)
|
|
45
|
+
.then(() => {
|
|
46
|
+
window.localStorage.removeItem(EMAIL_FOR_SIGN_IN_KEY);
|
|
47
|
+
window.history.replaceState({}, document.title, '/login');
|
|
48
|
+
setCompletingEmailLink(false);
|
|
49
|
+
})
|
|
50
|
+
.catch(() => {
|
|
51
|
+
setCompletingEmailLink(false);
|
|
52
|
+
setEmailForLinkCompletion(emailForSignIn);
|
|
53
|
+
});
|
|
54
|
+
} else {
|
|
55
|
+
setCompletingEmailLink(true);
|
|
56
|
+
setEmailForLinkCompletion('');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}, [signInWithEmailLink, isSignInWithEmailLink]);
|
|
60
|
+
|
|
61
|
+
const handleGoogleSignIn = async () => {
|
|
62
|
+
clearError();
|
|
63
|
+
await signInWithGoogle();
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const handleEmailAuth = async (e: React.FormEvent) => {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
clearError();
|
|
69
|
+
if (isSignUp) {
|
|
70
|
+
await signUpWithEmail(email, password, displayName);
|
|
71
|
+
} else {
|
|
72
|
+
await signInWithEmail(email, password);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const handleCompleteEmailLink = async (e: React.FormEvent) => {
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
if (!emailForLinkCompletion.trim()) return;
|
|
79
|
+
|
|
80
|
+
await signInWithEmailLink(emailForLinkCompletion, window.location.href);
|
|
81
|
+
window.localStorage.removeItem(EMAIL_FOR_SIGN_IN_KEY);
|
|
82
|
+
window.history.replaceState({}, document.title, '/login');
|
|
83
|
+
setCompletingEmailLink(false);
|
|
84
|
+
setEmailForLinkCompletion('');
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
|
89
|
+
<div className="max-w-md w-full space-y-8 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-8 shadow-lg">
|
|
90
|
+
<div>
|
|
91
|
+
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-gray-100">
|
|
92
|
+
{completingEmailLink
|
|
93
|
+
? 'Complete sign-in'
|
|
94
|
+
: isSignUp
|
|
95
|
+
? 'Create your account'
|
|
96
|
+
: 'Sign in to your account'}
|
|
97
|
+
</h2>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<div className="mt-8 space-y-6">
|
|
101
|
+
{completingEmailLink && (
|
|
102
|
+
<form onSubmit={handleCompleteEmailLink} className="space-y-4">
|
|
103
|
+
<div>
|
|
104
|
+
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
|
105
|
+
Please enter your email address to complete sign-in.
|
|
106
|
+
</p>
|
|
107
|
+
<input
|
|
108
|
+
type="email"
|
|
109
|
+
autoComplete="email"
|
|
110
|
+
required
|
|
111
|
+
value={emailForLinkCompletion}
|
|
112
|
+
onChange={(e) => setEmailForLinkCompletion(e.target.value)}
|
|
113
|
+
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
|
114
|
+
placeholder="Enter your email"
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
{error && (
|
|
119
|
+
<div className="text-red-600 dark:text-red-300 text-sm text-center bg-red-50 dark:bg-red-900/20 p-3 rounded-md border border-red-200 dark:border-red-800">
|
|
120
|
+
{error}
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
<button
|
|
125
|
+
type="submit"
|
|
126
|
+
disabled={loading || !emailForLinkCompletion.trim()}
|
|
127
|
+
className="w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
|
128
|
+
>
|
|
129
|
+
{loading ? 'Signing in...' : 'Complete sign-in'}
|
|
130
|
+
</button>
|
|
131
|
+
</form>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{!completingEmailLink && (
|
|
135
|
+
<>
|
|
136
|
+
<button
|
|
137
|
+
onClick={handleGoogleSignIn}
|
|
138
|
+
disabled={loading}
|
|
139
|
+
className="w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
|
140
|
+
>
|
|
141
|
+
{loading ? 'Signing in...' : 'Sign in with Google'}
|
|
142
|
+
</button>
|
|
143
|
+
|
|
144
|
+
<div className="relative">
|
|
145
|
+
<div className="absolute inset-0 flex items-center">
|
|
146
|
+
<div className="w-full border-t border-gray-300 dark:border-gray-700" />
|
|
147
|
+
</div>
|
|
148
|
+
<div className="relative flex justify-center text-sm">
|
|
149
|
+
<span className="px-2 bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400">
|
|
150
|
+
Or sign in with email
|
|
151
|
+
</span>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<form className="space-y-4" onSubmit={handleEmailAuth}>
|
|
156
|
+
{isSignUp && (
|
|
157
|
+
<input
|
|
158
|
+
type="text"
|
|
159
|
+
required
|
|
160
|
+
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
|
161
|
+
placeholder="Display Name"
|
|
162
|
+
value={displayName}
|
|
163
|
+
onChange={(e) => setDisplayName(e.target.value)}
|
|
164
|
+
/>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
<input
|
|
168
|
+
type="email"
|
|
169
|
+
autoComplete="email"
|
|
170
|
+
required
|
|
171
|
+
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
|
172
|
+
placeholder="Email address"
|
|
173
|
+
value={email}
|
|
174
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
175
|
+
/>
|
|
176
|
+
|
|
177
|
+
<input
|
|
178
|
+
type="password"
|
|
179
|
+
autoComplete={isSignUp ? 'new-password' : 'current-password'}
|
|
180
|
+
required
|
|
181
|
+
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
|
182
|
+
placeholder="Password"
|
|
183
|
+
value={password}
|
|
184
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
185
|
+
/>
|
|
186
|
+
|
|
187
|
+
{error && (
|
|
188
|
+
<div className="text-red-600 dark:text-red-300 text-sm text-center bg-red-50 dark:bg-red-900/20 p-3 rounded-md border border-red-200 dark:border-red-800">
|
|
189
|
+
{error}
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
|
|
193
|
+
<button
|
|
194
|
+
type="submit"
|
|
195
|
+
disabled={loading}
|
|
196
|
+
className="w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
|
197
|
+
>
|
|
198
|
+
{loading ? 'Processing...' : isSignUp ? 'Sign Up' : 'Sign In'}
|
|
199
|
+
</button>
|
|
200
|
+
</form>
|
|
201
|
+
|
|
202
|
+
<div className="text-center">
|
|
203
|
+
<button
|
|
204
|
+
onClick={() => setIsSignUp(!isSignUp)}
|
|
205
|
+
className="text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 font-medium text-sm"
|
|
206
|
+
>
|
|
207
|
+
{isSignUp ? 'Already have an account? Sign in' : "Don't have an account? Sign up"}
|
|
208
|
+
</button>
|
|
209
|
+
</div>
|
|
210
|
+
</>
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
export default Login;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev persona switch — view the app as each identity its domain implies.
|
|
3
|
+
*
|
|
4
|
+
* Renders only when auth is mocked (no Firebase credentials), so it cannot appear
|
|
5
|
+
* in a real deployment. Role gates and ownership-scoped lists are the parts of an
|
|
6
|
+
* app that look identical whether they work or are simply empty; switching viewer
|
|
7
|
+
* in one click is how you tell the difference.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React from 'react';
|
|
11
|
+
import { HStack, Select, Typography } from '@almadar/ui';
|
|
12
|
+
import { isAuthEnabled } from '../../../config/firebase';
|
|
13
|
+
import { listMockAccounts, onMockRosterChanged } from '../../../config/mockAuth';
|
|
14
|
+
import { authService } from '../authService';
|
|
15
|
+
import { useAuthContext } from '../AuthContext';
|
|
16
|
+
|
|
17
|
+
export function PersonaSwitcher(): React.ReactElement | null {
|
|
18
|
+
const { user } = useAuthContext();
|
|
19
|
+
// The roster arrives over HTTP after first paint. Without this the picker
|
|
20
|
+
// renders its empty pre-fetch state and never updates, because the signed-out
|
|
21
|
+
// viewer never changes and so the auth listener never fires.
|
|
22
|
+
const [accounts, setAccounts] = React.useState(() => listMockAccounts());
|
|
23
|
+
|
|
24
|
+
React.useEffect(() => onMockRosterChanged(() => setAccounts(listMockAccounts())), []);
|
|
25
|
+
|
|
26
|
+
if (isAuthEnabled()) return null;
|
|
27
|
+
|
|
28
|
+
const options = accounts.map((p) => ({
|
|
29
|
+
value: p.id,
|
|
30
|
+
label: `${String(p.name ?? p.id)} — ${String(p.role ?? 'no role')}`,
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<HStack gap="sm" align="center">
|
|
35
|
+
<Typography variant="caption" color="muted">
|
|
36
|
+
Viewing as
|
|
37
|
+
</Typography>
|
|
38
|
+
<Select
|
|
39
|
+
options={options}
|
|
40
|
+
value={user?.uid ?? ''}
|
|
41
|
+
placeholder="Signed out"
|
|
42
|
+
onChange={(event) => {
|
|
43
|
+
void authService.signInAsPersona(event.target.value);
|
|
44
|
+
}}
|
|
45
|
+
/>
|
|
46
|
+
</HStack>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default PersonaSwitcher;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Navigate } from 'react-router-dom';
|
|
3
|
+
import { useAuthContext } from '../AuthContext';
|
|
4
|
+
import { isAuthEnabled } from '../../../config/firebase';
|
|
5
|
+
|
|
6
|
+
interface ProtectedRouteProps {
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
|
11
|
+
const { user, loading } = useAuthContext();
|
|
12
|
+
|
|
13
|
+
if (loading) {
|
|
14
|
+
return (
|
|
15
|
+
<div className="min-h-screen flex items-center justify-center">
|
|
16
|
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600" />
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Auth-disabled mode (no Firebase config): nothing can sign in, so
|
|
22
|
+
// gating would strand every route on /login — pass straight through.
|
|
23
|
+
// Checked after `loading` so a configured-but-initializing Firebase
|
|
24
|
+
// still shows the spinner instead of flashing protected content.
|
|
25
|
+
if (!isAuthEnabled()) {
|
|
26
|
+
return <>{children}</>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!user) {
|
|
30
|
+
return <Navigate to="/login" replace />;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return <>{children}</>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export default ProtectedRoute;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect } from 'react';
|
|
2
|
+
import { useAuthContext } from '../AuthContext';
|
|
3
|
+
|
|
4
|
+
const UserProfile: React.FC = () => {
|
|
5
|
+
const { user, loading, signOut } = useAuthContext();
|
|
6
|
+
const [open, setOpen] = useState(false);
|
|
7
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
8
|
+
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
11
|
+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
12
|
+
};
|
|
13
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
14
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
15
|
+
}, []);
|
|
16
|
+
|
|
17
|
+
if (!user) return null;
|
|
18
|
+
|
|
19
|
+
const handleSignOut = async () => {
|
|
20
|
+
setOpen(false);
|
|
21
|
+
await signOut();
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div ref={ref} className="relative">
|
|
26
|
+
<button
|
|
27
|
+
onClick={() => setOpen(!open)}
|
|
28
|
+
className="flex items-center space-x-2 hover:opacity-80 transition-opacity"
|
|
29
|
+
>
|
|
30
|
+
{user.photoURL ? (
|
|
31
|
+
<img
|
|
32
|
+
className="h-8 w-8 rounded-full ring-2 ring-gray-200 dark:ring-gray-700"
|
|
33
|
+
src={user.photoURL}
|
|
34
|
+
alt={user.displayName || 'User'}
|
|
35
|
+
/>
|
|
36
|
+
) : (
|
|
37
|
+
<div className="h-8 w-8 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-sm font-medium text-gray-600 dark:text-gray-300">
|
|
38
|
+
{(user.displayName || user.email || 'U')[0].toUpperCase()}
|
|
39
|
+
</div>
|
|
40
|
+
)}
|
|
41
|
+
</button>
|
|
42
|
+
|
|
43
|
+
{open && (
|
|
44
|
+
<div className="absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black/5 dark:ring-white/10 z-50">
|
|
45
|
+
<div className="px-4 py-3 border-b border-gray-100 dark:border-gray-700">
|
|
46
|
+
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
|
47
|
+
{user.displayName || 'User'}
|
|
48
|
+
</p>
|
|
49
|
+
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">
|
|
50
|
+
{user.email}
|
|
51
|
+
</p>
|
|
52
|
+
</div>
|
|
53
|
+
<div className="py-1">
|
|
54
|
+
<button
|
|
55
|
+
onClick={handleSignOut}
|
|
56
|
+
disabled={loading}
|
|
57
|
+
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
|
|
58
|
+
>
|
|
59
|
+
Sign Out
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export default UserProfile;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Context
|
|
2
|
+
export { AuthProvider, useAuthContext } from './AuthContext';
|
|
3
|
+
|
|
4
|
+
// Components
|
|
5
|
+
export { default as Login } from './components/Login';
|
|
6
|
+
export { default as UserProfile } from './components/UserProfile';
|
|
7
|
+
export { default as ProtectedRoute } from './components/ProtectedRoute';
|
|
8
|
+
export { PersonaSwitcher } from './components/PersonaSwitcher';
|
|
9
|
+
|
|
10
|
+
// Service
|
|
11
|
+
export { authService } from './authService';
|
|
12
|
+
|
|
13
|
+
// Types
|
|
14
|
+
export type { AuthContextType, AuthViewer, LoginCredentials, SignUpCredentials } from './types';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The signed-in viewer, as the auth UI reads it. Firebase's `User` and the mocked
|
|
3
|
+
* `MockUser` are both structurally assignable to this, so the provider carries
|
|
4
|
+
* either without a cast and without the UI knowing which one is live.
|
|
5
|
+
*
|
|
6
|
+
* A type alias, not an interface: only aliases get TS's implicit index signature,
|
|
7
|
+
* which is what lets this be passed to `normalizeUserContext(claims: RawUserClaims)`
|
|
8
|
+
* without a cast.
|
|
9
|
+
*/
|
|
10
|
+
export type AuthViewer = {
|
|
11
|
+
uid: string;
|
|
12
|
+
email: string | null;
|
|
13
|
+
displayName: string | null;
|
|
14
|
+
photoURL: string | null;
|
|
15
|
+
/** Present only for mocked personas; real Firebase claims carry no role. */
|
|
16
|
+
role?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface LoginCredentials {
|
|
20
|
+
email: string;
|
|
21
|
+
password: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SignUpCredentials extends LoginCredentials {
|
|
25
|
+
displayName?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AuthContextType {
|
|
29
|
+
user: AuthViewer | null;
|
|
30
|
+
loading: boolean;
|
|
31
|
+
error: string | null;
|
|
32
|
+
signInWithGoogle: () => Promise<void>;
|
|
33
|
+
signOut: () => Promise<void>;
|
|
34
|
+
signInWithEmail: (email: string, password: string) => Promise<void>;
|
|
35
|
+
signUpWithEmail: (email: string, password: string, displayName?: string) => Promise<void>;
|
|
36
|
+
sendSignInLinkToEmail: (email: string) => Promise<void>;
|
|
37
|
+
signInWithEmailLink: (email: string, emailLink: string) => Promise<void>;
|
|
38
|
+
isSignInWithEmailLink: (emailLink: string) => boolean;
|
|
39
|
+
clearError: () => void;
|
|
40
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/* Import Almadar theme - @import must precede @tailwind directives */
|
|
2
|
+
@import '@almadar/ui/themes/minimalist.css';
|
|
3
|
+
|
|
4
|
+
@tailwind base;
|
|
5
|
+
@tailwind components;
|
|
6
|
+
@tailwind utilities;
|
|
7
|
+
|
|
8
|
+
/* Main slot fills the viewport edge-to-edge. Variant trees control their
|
|
9
|
+
* own width and padding via className utilities (max-w-*, mx-auto, px-*).
|
|
10
|
+
* The shell stays out of the way. */
|
|
11
|
+
|
|
12
|
+
/* Global transition baseline: smooth state changes on interactive elements */
|
|
13
|
+
button,
|
|
14
|
+
a,
|
|
15
|
+
[role="button"],
|
|
16
|
+
[data-entity-row],
|
|
17
|
+
[data-interactive] {
|
|
18
|
+
transition: all var(--transition-normal, 250ms) var(--transition-timing, cubic-bezier(0.4, 0, 0.2, 1));
|
|
19
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation Module for Compiled Shells
|
|
3
|
+
*
|
|
4
|
+
* Re-exports schema-driven navigation from @almadar/ui/renderer.
|
|
5
|
+
* This module provides unified navigation that:
|
|
6
|
+
* - Finds pages by path pattern (supports :id params)
|
|
7
|
+
* - Switches active page via NavigationContext
|
|
8
|
+
* - Fires INIT events with merged payload (route params + explicit)
|
|
9
|
+
* - Optionally updates browser URL via history.pushState
|
|
10
|
+
*
|
|
11
|
+
* Usage in generated pages:
|
|
12
|
+
* ```tsx
|
|
13
|
+
* import { useNavigateTo, useInitPayload } from '../navigation';
|
|
14
|
+
*
|
|
15
|
+
* function InspectionsPage() {
|
|
16
|
+
* const navigateTo = useNavigateTo();
|
|
17
|
+
* const initPayload = useInitPayload();
|
|
18
|
+
*
|
|
19
|
+
* const handleRowClick = (item) => {
|
|
20
|
+
* navigateTo(`/inspection/${item.id}`, { id: item.id });
|
|
21
|
+
* };
|
|
22
|
+
*
|
|
23
|
+
* // Use initPayload for INIT event handling
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* @packageDocumentation
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
// Re-export all navigation utilities from @almadar/ui/renderer
|
|
31
|
+
export {
|
|
32
|
+
// Context and Provider
|
|
33
|
+
NavigationProvider,
|
|
34
|
+
useNavigation,
|
|
35
|
+
useNavigateTo,
|
|
36
|
+
useNavigationState,
|
|
37
|
+
useInitPayload,
|
|
38
|
+
useActivePage,
|
|
39
|
+
useNavigationId,
|
|
40
|
+
// Path utilities
|
|
41
|
+
matchPath,
|
|
42
|
+
extractRouteParams,
|
|
43
|
+
pathMatches,
|
|
44
|
+
// Page finding utilities
|
|
45
|
+
findPageByPath,
|
|
46
|
+
findPageByName,
|
|
47
|
+
getDefaultPage,
|
|
48
|
+
getAllPages,
|
|
49
|
+
} from '@almadar/ui/renderer';
|
|
50
|
+
|
|
51
|
+
export type {
|
|
52
|
+
NavigationState,
|
|
53
|
+
NavigationContextValue,
|
|
54
|
+
NavigationProviderProps,
|
|
55
|
+
} from '@almadar/ui/renderer';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pages Placeholder
|
|
3
|
+
*
|
|
4
|
+
* This directory receives generated page components:
|
|
5
|
+
* - Route pages from OrbitalSchema
|
|
6
|
+
* - Layout components
|
|
7
|
+
*
|
|
8
|
+
* DO NOT EDIT - Contents are overwritten by compiler
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// {{GENERATED_PAGE_EXPORTS}}
|
|
12
|
+
export {};
|