@tonycodes/auth-react 1.1.0 → 1.2.1
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/.github/workflows/publish.yml +33 -0
- package/dist/SignInForm.d.ts +6 -1
- package/dist/SignInForm.d.ts.map +1 -1
- package/dist/SignInForm.js +141 -16
- package/dist/SignInForm.js.map +1 -1
- package/package.json +8 -29
- package/src/AuthCallback.tsx +128 -0
- package/src/AuthProvider.tsx +331 -0
- package/src/OrganizationSwitcher.tsx +75 -0
- package/src/SignInForm.tsx +323 -0
- package/src/components.tsx +40 -0
- package/src/index.ts +9 -0
- package/src/types.ts +61 -0
- package/src/useAuth.ts +19 -0
- package/src/useProviders.ts +136 -0
- package/src/validateConfig.ts +94 -0
- package/tsconfig.json +18 -0
- package/dist/style.css +0 -1
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useAuthConfig } from './useAuth.js';
|
|
3
|
+
import { useProviders, type SSOProvider } from './useProviders.js';
|
|
4
|
+
|
|
5
|
+
type Mode = 'signin' | 'signup';
|
|
6
|
+
|
|
7
|
+
const ERROR_MESSAGES: Record<string, string> = {
|
|
8
|
+
account_not_found: 'No account found with that login. Sign up to create one.',
|
|
9
|
+
oauth_failed: 'Something went wrong during sign in. Please try again.',
|
|
10
|
+
missing_code: 'Authorization failed. Please try again.',
|
|
11
|
+
invalid_state: 'Session expired. Please try again.',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// ─── Inline Styles ───────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
const styles = {
|
|
17
|
+
tabContainer: (isDark: boolean) => ({
|
|
18
|
+
display: 'flex',
|
|
19
|
+
gap: '4px',
|
|
20
|
+
padding: '4px',
|
|
21
|
+
backgroundColor: isDark ? '#1f2937' : '#f4f4f5',
|
|
22
|
+
borderRadius: '12px',
|
|
23
|
+
marginBottom: '24px',
|
|
24
|
+
} as React.CSSProperties),
|
|
25
|
+
|
|
26
|
+
tab: (isActive: boolean, isDark: boolean) => ({
|
|
27
|
+
flex: 1,
|
|
28
|
+
padding: '8px 0',
|
|
29
|
+
fontSize: '14px',
|
|
30
|
+
fontWeight: 500,
|
|
31
|
+
borderRadius: '8px',
|
|
32
|
+
textAlign: 'center' as const,
|
|
33
|
+
cursor: 'pointer',
|
|
34
|
+
transition: 'background-color 0.15s, color 0.15s',
|
|
35
|
+
border: 'none',
|
|
36
|
+
backgroundColor: isActive
|
|
37
|
+
? (isDark ? '#374151' : '#ffffff')
|
|
38
|
+
: 'transparent',
|
|
39
|
+
color: isActive
|
|
40
|
+
? (isDark ? '#f9fafb' : '#18181b')
|
|
41
|
+
: (isDark ? '#9ca3af' : '#71717a'),
|
|
42
|
+
boxShadow: isActive ? '0 1px 2px rgba(0,0,0,0.05)' : 'none',
|
|
43
|
+
} as React.CSSProperties),
|
|
44
|
+
|
|
45
|
+
error: (isDark: boolean) => ({
|
|
46
|
+
marginBottom: '16px',
|
|
47
|
+
padding: '12px',
|
|
48
|
+
borderRadius: '8px',
|
|
49
|
+
backgroundColor: isDark ? 'rgba(239,68,68,0.15)' : '#fef2f2',
|
|
50
|
+
border: `1px solid ${isDark ? 'rgba(239,68,68,0.3)' : '#fecaca'}`,
|
|
51
|
+
color: isDark ? '#fca5a5' : '#b91c1c',
|
|
52
|
+
fontSize: '14px',
|
|
53
|
+
} as React.CSSProperties),
|
|
54
|
+
|
|
55
|
+
buttonGroup: {
|
|
56
|
+
display: 'flex',
|
|
57
|
+
flexDirection: 'column' as const,
|
|
58
|
+
gap: '12px',
|
|
59
|
+
} as React.CSSProperties,
|
|
60
|
+
|
|
61
|
+
buttonBase: {
|
|
62
|
+
width: '100%',
|
|
63
|
+
display: 'flex',
|
|
64
|
+
alignItems: 'center',
|
|
65
|
+
justifyContent: 'center',
|
|
66
|
+
gap: '12px',
|
|
67
|
+
padding: '12px 24px',
|
|
68
|
+
fontWeight: 500,
|
|
69
|
+
fontSize: '15px',
|
|
70
|
+
borderRadius: '12px',
|
|
71
|
+
cursor: 'pointer',
|
|
72
|
+
border: 'none',
|
|
73
|
+
transition: 'opacity 0.15s',
|
|
74
|
+
fontFamily: 'inherit',
|
|
75
|
+
} as React.CSSProperties,
|
|
76
|
+
|
|
77
|
+
githubBtn: (isDark: boolean) => ({
|
|
78
|
+
backgroundColor: isDark ? '#ffffff' : '#18181b',
|
|
79
|
+
color: isDark ? '#18181b' : '#ffffff',
|
|
80
|
+
} as React.CSSProperties),
|
|
81
|
+
|
|
82
|
+
googleBtn: (isDark: boolean) => ({
|
|
83
|
+
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
|
84
|
+
color: isDark ? '#e5e7eb' : '#18181b',
|
|
85
|
+
border: `1px solid ${isDark ? '#374151' : '#d4d4d8'}`,
|
|
86
|
+
} as React.CSSProperties),
|
|
87
|
+
|
|
88
|
+
appleBtn: {
|
|
89
|
+
backgroundColor: '#000000',
|
|
90
|
+
color: '#ffffff',
|
|
91
|
+
} as React.CSSProperties,
|
|
92
|
+
|
|
93
|
+
bitbucketBtn: {
|
|
94
|
+
backgroundColor: '#2563eb',
|
|
95
|
+
color: '#ffffff',
|
|
96
|
+
} as React.CSSProperties,
|
|
97
|
+
|
|
98
|
+
spinner: (isDark: boolean) => ({
|
|
99
|
+
width: '20px',
|
|
100
|
+
height: '20px',
|
|
101
|
+
border: `2px solid ${isDark ? '#374151' : '#d4d4d8'}`,
|
|
102
|
+
borderTopColor: isDark ? '#9ca3af' : '#52525b',
|
|
103
|
+
borderRadius: '50%',
|
|
104
|
+
animation: 'tonycodes-auth-spin 0.6s linear infinite',
|
|
105
|
+
margin: '16px auto',
|
|
106
|
+
} as React.CSSProperties),
|
|
107
|
+
|
|
108
|
+
terms: (isDark: boolean) => ({
|
|
109
|
+
marginTop: '24px',
|
|
110
|
+
textAlign: 'center' as const,
|
|
111
|
+
fontSize: '12px',
|
|
112
|
+
color: isDark ? '#6b7280' : '#a1a1aa',
|
|
113
|
+
} as React.CSSProperties),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// ─── Provider Icons (inline SVG) ─────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
function GithubIcon() {
|
|
119
|
+
return (
|
|
120
|
+
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
|
121
|
+
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2z" />
|
|
122
|
+
</svg>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function GoogleIcon() {
|
|
127
|
+
return (
|
|
128
|
+
<svg viewBox="0 0 24 24" width="20" height="20">
|
|
129
|
+
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
|
|
130
|
+
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
|
131
|
+
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
|
|
132
|
+
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
|
133
|
+
</svg>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function AppleIcon() {
|
|
138
|
+
return (
|
|
139
|
+
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
|
140
|
+
<path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
|
|
141
|
+
</svg>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function BitbucketIcon() {
|
|
146
|
+
return (
|
|
147
|
+
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
|
148
|
+
<path d="M.778 1.213a.768.768 0 0 0-.768.892l3.263 19.81c.084.5.515.868 1.022.873H19.95a.772.772 0 0 0 .77-.646l3.27-20.03a.768.768 0 0 0-.768-.891L.778 1.213zM14.52 15.53H9.522L8.17 8.466h7.561l-1.211 7.064z" />
|
|
149
|
+
</svg>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── Component ────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
interface SignInFormProps {
|
|
156
|
+
/** List of provider IDs to show (for manual control). Overrides autoFetch when provided. */
|
|
157
|
+
providers?: SSOProvider[];
|
|
158
|
+
/** Whether to automatically fetch providers from the auth service. Defaults to true. */
|
|
159
|
+
autoFetch?: boolean;
|
|
160
|
+
/** Color theme. Defaults to 'auto' (follows prefers-color-scheme). */
|
|
161
|
+
theme?: 'light' | 'dark' | 'auto';
|
|
162
|
+
/** Optional inline styles applied to the root container. */
|
|
163
|
+
style?: React.CSSProperties;
|
|
164
|
+
/** Optional CSS class applied to the root container. */
|
|
165
|
+
className?: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function SignInForm({
|
|
169
|
+
providers: propProviders,
|
|
170
|
+
autoFetch = true,
|
|
171
|
+
theme = 'auto',
|
|
172
|
+
style,
|
|
173
|
+
className,
|
|
174
|
+
}: SignInFormProps) {
|
|
175
|
+
const config = useAuthConfig();
|
|
176
|
+
const { providers: fetchedProviders, isLoading: providersLoading } = useProviders();
|
|
177
|
+
const [activeTab, setActiveTab] = useState<Mode>('signin');
|
|
178
|
+
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
179
|
+
const [returnTo, setReturnTo] = useState('/');
|
|
180
|
+
const [isDark, setIsDark] = useState(false);
|
|
181
|
+
|
|
182
|
+
// Resolve theme
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (theme === 'dark') {
|
|
185
|
+
setIsDark(true);
|
|
186
|
+
} else if (theme === 'light') {
|
|
187
|
+
setIsDark(false);
|
|
188
|
+
} else {
|
|
189
|
+
// auto — check prefers-color-scheme
|
|
190
|
+
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
|
191
|
+
setIsDark(mq.matches);
|
|
192
|
+
const handler = (e: MediaQueryListEvent) => setIsDark(e.matches);
|
|
193
|
+
mq.addEventListener('change', handler);
|
|
194
|
+
return () => mq.removeEventListener('change', handler);
|
|
195
|
+
}
|
|
196
|
+
}, [theme]);
|
|
197
|
+
|
|
198
|
+
// Use prop providers if explicitly provided, otherwise use fetched providers
|
|
199
|
+
const enabledProviders: SSOProvider[] = propProviders
|
|
200
|
+
? propProviders
|
|
201
|
+
: autoFetch
|
|
202
|
+
? fetchedProviders.map((p) => p.id)
|
|
203
|
+
: ['github']; // fallback default
|
|
204
|
+
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
const params = new URLSearchParams(window.location.search);
|
|
207
|
+
const errorParam = params.get('error');
|
|
208
|
+
const tabParam = params.get('tab') as Mode | null;
|
|
209
|
+
const returnToParam = params.get('returnTo');
|
|
210
|
+
|
|
211
|
+
if (returnToParam) setReturnTo(returnToParam);
|
|
212
|
+
|
|
213
|
+
if (errorParam) {
|
|
214
|
+
setErrorMessage(ERROR_MESSAGES[errorParam] || `Authentication error: ${errorParam}`);
|
|
215
|
+
if (errorParam === 'account_not_found') {
|
|
216
|
+
setActiveTab('signup');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (tabParam === 'signin' || tabParam === 'signup') {
|
|
221
|
+
setActiveTab(tabParam);
|
|
222
|
+
}
|
|
223
|
+
}, []);
|
|
224
|
+
|
|
225
|
+
function handleOAuth(provider: string) {
|
|
226
|
+
const redirectUri = `${config.appUrl}/auth/callback`;
|
|
227
|
+
const state = btoa(JSON.stringify({ returnTo }));
|
|
228
|
+
const params = new URLSearchParams({
|
|
229
|
+
client_id: config.clientId,
|
|
230
|
+
redirect_uri: redirectUri,
|
|
231
|
+
state,
|
|
232
|
+
provider,
|
|
233
|
+
mode: activeTab,
|
|
234
|
+
});
|
|
235
|
+
window.location.href = `${config.authUrl}/authorize?${params}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function switchTab(tab: Mode) {
|
|
239
|
+
setActiveTab(tab);
|
|
240
|
+
setErrorMessage(null);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const providerName = (id: string) => {
|
|
244
|
+
const names: Record<string, string> = {
|
|
245
|
+
github: 'GitHub',
|
|
246
|
+
google: 'Google',
|
|
247
|
+
apple: 'Apple',
|
|
248
|
+
bitbucket: 'Bitbucket',
|
|
249
|
+
};
|
|
250
|
+
return names[id] || id;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const buttonText = (provider: string) => {
|
|
254
|
+
const name = providerName(provider);
|
|
255
|
+
return activeTab === 'signin' ? `Sign in with ${name}` : `Sign up with ${name}`;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const providerStyles: Record<string, React.CSSProperties> = {
|
|
259
|
+
github: { ...styles.buttonBase, ...styles.githubBtn(isDark) },
|
|
260
|
+
google: { ...styles.buttonBase, ...styles.googleBtn(isDark) },
|
|
261
|
+
apple: { ...styles.buttonBase, ...styles.appleBtn },
|
|
262
|
+
bitbucket: { ...styles.buttonBase, ...styles.bitbucketBtn },
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const providerIcons: Record<string, React.ReactNode> = {
|
|
266
|
+
github: <GithubIcon />,
|
|
267
|
+
google: <GoogleIcon />,
|
|
268
|
+
apple: <AppleIcon />,
|
|
269
|
+
bitbucket: <BitbucketIcon />,
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
return (
|
|
273
|
+
<div className={className} style={style}>
|
|
274
|
+
{/* Inject keyframe animation */}
|
|
275
|
+
<style>{`@keyframes tonycodes-auth-spin { to { transform: rotate(360deg) } }`}</style>
|
|
276
|
+
|
|
277
|
+
{/* Tab toggle */}
|
|
278
|
+
<div style={styles.tabContainer(isDark)}>
|
|
279
|
+
<button type="button" style={styles.tab(activeTab === 'signin', isDark)} onClick={() => switchTab('signin')}>
|
|
280
|
+
Sign In
|
|
281
|
+
</button>
|
|
282
|
+
<button type="button" style={styles.tab(activeTab === 'signup', isDark)} onClick={() => switchTab('signup')}>
|
|
283
|
+
Sign Up
|
|
284
|
+
</button>
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
{/* Error message */}
|
|
288
|
+
{errorMessage && (
|
|
289
|
+
<div style={styles.error(isDark)}>
|
|
290
|
+
{errorMessage}
|
|
291
|
+
</div>
|
|
292
|
+
)}
|
|
293
|
+
|
|
294
|
+
{/* Loading state */}
|
|
295
|
+
{autoFetch && !propProviders && providersLoading ? (
|
|
296
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '16px 0' }}>
|
|
297
|
+
<div style={styles.spinner(isDark)} />
|
|
298
|
+
</div>
|
|
299
|
+
) : (
|
|
300
|
+
<div style={styles.buttonGroup}>
|
|
301
|
+
{enabledProviders.map((id) => (
|
|
302
|
+
<button
|
|
303
|
+
key={id}
|
|
304
|
+
type="button"
|
|
305
|
+
style={providerStyles[id] || styles.buttonBase}
|
|
306
|
+
onClick={() => handleOAuth(id)}
|
|
307
|
+
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.opacity = '0.9'; }}
|
|
308
|
+
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.opacity = '1'; }}
|
|
309
|
+
>
|
|
310
|
+
{providerIcons[id]}
|
|
311
|
+
<span>{buttonText(id)}</span>
|
|
312
|
+
</button>
|
|
313
|
+
))}
|
|
314
|
+
</div>
|
|
315
|
+
)}
|
|
316
|
+
|
|
317
|
+
{/* Terms */}
|
|
318
|
+
<p style={styles.terms(isDark)}>
|
|
319
|
+
By continuing, you agree to our terms of service.
|
|
320
|
+
</p>
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { useAuth } from './useAuth.js';
|
|
3
|
+
|
|
4
|
+
interface AuthGateProps {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Renders children only when user is authenticated
|
|
10
|
+
*/
|
|
11
|
+
export function SignedIn({ children }: AuthGateProps) {
|
|
12
|
+
const { isAuthenticated, isLoading } = useAuth();
|
|
13
|
+
if (isLoading || !isAuthenticated) return null;
|
|
14
|
+
return <>{children}</>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Renders children only when user is NOT authenticated
|
|
19
|
+
*/
|
|
20
|
+
export function SignedOut({ children }: AuthGateProps) {
|
|
21
|
+
const { isAuthenticated, isLoading } = useAuth();
|
|
22
|
+
if (isLoading || isAuthenticated) return null;
|
|
23
|
+
return <>{children}</>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Redirects to auth service sign-in when user is not authenticated
|
|
28
|
+
*/
|
|
29
|
+
export function RedirectToSignIn() {
|
|
30
|
+
const { isAuthenticated, isLoading, login } = useAuth();
|
|
31
|
+
|
|
32
|
+
if (isLoading) return null;
|
|
33
|
+
|
|
34
|
+
if (!isAuthenticated) {
|
|
35
|
+
login();
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return null;
|
|
40
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { AuthProvider } from './AuthProvider.js';
|
|
2
|
+
export { useAuth, useAuthConfig } from './useAuth.js';
|
|
3
|
+
export { useProviders, type SSOProvider, type ProviderInfo, type UseProvidersResult } from './useProviders.js';
|
|
4
|
+
export { SignedIn, SignedOut, RedirectToSignIn } from './components.js';
|
|
5
|
+
export { OrganizationSwitcher } from './OrganizationSwitcher.js';
|
|
6
|
+
export { AuthCallback } from './AuthCallback.js';
|
|
7
|
+
export { SignInForm } from './SignInForm.js';
|
|
8
|
+
export { AuthConfigError } from './validateConfig.js';
|
|
9
|
+
export type { AuthConfig, ResolvedAuthConfig, AuthUser, AuthOrganization, AuthState } from './types.js';
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export interface AuthConfig {
|
|
2
|
+
/** Client ID registered with auth service */
|
|
3
|
+
clientId: string;
|
|
4
|
+
/** Auth service URL. Defaults to https://auth.tony.codes */
|
|
5
|
+
authUrl?: string;
|
|
6
|
+
/** This app's base URL. Auto-discovered from server or defaults to window.location.origin */
|
|
7
|
+
appUrl?: string;
|
|
8
|
+
/**
|
|
9
|
+
* API base URL for proxied auth endpoints (callback, refresh, logout).
|
|
10
|
+
* Required if your API runs on a different subdomain than your frontend
|
|
11
|
+
* (e.g., api.myapp.test vs myapp.test).
|
|
12
|
+
* Auto-discovered from server or defaults to appUrl.
|
|
13
|
+
*/
|
|
14
|
+
apiUrl?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Resolved config with all URLs populated (after discovery) */
|
|
18
|
+
export interface ResolvedAuthConfig {
|
|
19
|
+
clientId: string;
|
|
20
|
+
authUrl: string;
|
|
21
|
+
appUrl: string;
|
|
22
|
+
apiUrl: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AuthUser {
|
|
26
|
+
id: string;
|
|
27
|
+
email: string;
|
|
28
|
+
name: string;
|
|
29
|
+
role: string;
|
|
30
|
+
imageUrl: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AuthOrganization {
|
|
34
|
+
id: string;
|
|
35
|
+
name: string;
|
|
36
|
+
slug: string;
|
|
37
|
+
imageUrl: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AuthState {
|
|
41
|
+
isAuthenticated: boolean;
|
|
42
|
+
isLoading: boolean;
|
|
43
|
+
user: AuthUser | null;
|
|
44
|
+
organization: AuthOrganization | null;
|
|
45
|
+
tenant: { id: string; name: string; slug: string } | null; // legacy alias
|
|
46
|
+
isAdmin: boolean;
|
|
47
|
+
isOwner: boolean;
|
|
48
|
+
orgRole: string;
|
|
49
|
+
isSuperAdmin: boolean;
|
|
50
|
+
isPlatformAdmin: boolean;
|
|
51
|
+
accessToken: string | null;
|
|
52
|
+
getAccessToken: () => Promise<string | null>;
|
|
53
|
+
login: (provider?: string) => void;
|
|
54
|
+
logout: () => Promise<void>;
|
|
55
|
+
switchOrganization: (orgId: string) => Promise<void>;
|
|
56
|
+
organizations: AuthOrganization[];
|
|
57
|
+
isLoggingOut: boolean;
|
|
58
|
+
isLoggingIn: boolean;
|
|
59
|
+
loginError: string | null;
|
|
60
|
+
impersonating: boolean;
|
|
61
|
+
}
|
package/src/useAuth.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useContext } from 'react';
|
|
2
|
+
import { AuthContext, AuthConfigContext } from './AuthProvider.js';
|
|
3
|
+
import type { ResolvedAuthConfig, AuthState } from './types.js';
|
|
4
|
+
|
|
5
|
+
export function useAuth(): AuthState {
|
|
6
|
+
const context = useContext(AuthContext);
|
|
7
|
+
if (!context) {
|
|
8
|
+
throw new Error('useAuth must be used within an AuthProvider');
|
|
9
|
+
}
|
|
10
|
+
return context;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useAuthConfig(): ResolvedAuthConfig {
|
|
14
|
+
const config = useContext(AuthConfigContext);
|
|
15
|
+
if (!config) {
|
|
16
|
+
throw new Error('useAuthConfig must be used within an AuthProvider');
|
|
17
|
+
}
|
|
18
|
+
return config;
|
|
19
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { useAuthConfig } from './useAuth.js';
|
|
3
|
+
|
|
4
|
+
export type SSOProvider = 'github' | 'google' | 'apple' | 'bitbucket';
|
|
5
|
+
|
|
6
|
+
export interface ProviderInfo {
|
|
7
|
+
id: SSOProvider;
|
|
8
|
+
name: string;
|
|
9
|
+
enabled: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface UseProvidersResult {
|
|
13
|
+
providers: ProviderInfo[];
|
|
14
|
+
isLoading: boolean;
|
|
15
|
+
error: string | null;
|
|
16
|
+
/** Force refetch, bypassing cache */
|
|
17
|
+
refresh: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ─── Module-level cache ──────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
interface CacheEntry {
|
|
23
|
+
providers: ProviderInfo[];
|
|
24
|
+
fetchedAt: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CACHE_TTL = 60_000; // 60 seconds
|
|
28
|
+
const cache = new Map<string, CacheEntry>();
|
|
29
|
+
|
|
30
|
+
function getCacheKey(authUrl: string, clientId: string): string {
|
|
31
|
+
return `${authUrl}::${clientId}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getCachedProviders(key: string): CacheEntry | null {
|
|
35
|
+
const entry = cache.get(key);
|
|
36
|
+
if (!entry) return null;
|
|
37
|
+
return entry;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isStale(entry: CacheEntry): boolean {
|
|
41
|
+
return Date.now() - entry.fetchedAt > CACHE_TTL;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function fetchProviders(authUrl: string, clientId: string): Promise<ProviderInfo[]> {
|
|
45
|
+
const url = new URL(`${authUrl}/providers`);
|
|
46
|
+
url.searchParams.set('client_id', clientId);
|
|
47
|
+
|
|
48
|
+
const res = await fetch(url.toString());
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
throw new Error('Failed to fetch providers');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const data = await res.json();
|
|
54
|
+
return data.providers || [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Hook ────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Hook to fetch available SSO providers from the auth service.
|
|
61
|
+
* Uses module-level cache with 60s TTL and stale-while-revalidate.
|
|
62
|
+
* Clears cache on window.focus for admin changes to take effect.
|
|
63
|
+
*/
|
|
64
|
+
export function useProviders(): UseProvidersResult {
|
|
65
|
+
const config = useAuthConfig();
|
|
66
|
+
const cacheKey = getCacheKey(config.authUrl, config.clientId);
|
|
67
|
+
|
|
68
|
+
// Initialize from cache if available
|
|
69
|
+
const cached = getCachedProviders(cacheKey);
|
|
70
|
+
const [providers, setProviders] = useState<ProviderInfo[]>(cached?.providers || []);
|
|
71
|
+
const [isLoading, setIsLoading] = useState(!cached);
|
|
72
|
+
const [error, setError] = useState<string | null>(null);
|
|
73
|
+
const [refreshCounter, setRefreshCounter] = useState(0);
|
|
74
|
+
|
|
75
|
+
const refresh = useCallback(() => {
|
|
76
|
+
cache.delete(cacheKey);
|
|
77
|
+
setRefreshCounter((c: number) => c + 1);
|
|
78
|
+
}, [cacheKey]);
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
let mounted = true;
|
|
82
|
+
|
|
83
|
+
async function doFetch(showLoading: boolean) {
|
|
84
|
+
if (showLoading) setIsLoading(true);
|
|
85
|
+
try {
|
|
86
|
+
const result = await fetchProviders(config.authUrl, config.clientId);
|
|
87
|
+
cache.set(cacheKey, { providers: result, fetchedAt: Date.now() });
|
|
88
|
+
if (mounted) {
|
|
89
|
+
setProviders(result);
|
|
90
|
+
setError(null);
|
|
91
|
+
}
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (mounted) {
|
|
94
|
+
setError(err instanceof Error ? err.message : 'Failed to fetch providers');
|
|
95
|
+
// Keep stale data if we have it
|
|
96
|
+
if (!cached) setProviders([]);
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
if (mounted) setIsLoading(false);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const entry = getCachedProviders(cacheKey);
|
|
104
|
+
|
|
105
|
+
if (!entry) {
|
|
106
|
+
// No cache — fetch with loading state
|
|
107
|
+
doFetch(true);
|
|
108
|
+
} else if (isStale(entry)) {
|
|
109
|
+
// Stale cache — show cached data, refresh in background
|
|
110
|
+
setProviders(entry.providers);
|
|
111
|
+
setIsLoading(false);
|
|
112
|
+
doFetch(false);
|
|
113
|
+
} else {
|
|
114
|
+
// Fresh cache — use it directly
|
|
115
|
+
setProviders(entry.providers);
|
|
116
|
+
setIsLoading(false);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Refetch on window focus (admin changes take effect when tab refocused)
|
|
120
|
+
function handleFocus() {
|
|
121
|
+
const current = getCachedProviders(cacheKey);
|
|
122
|
+
if (!current || isStale(current)) {
|
|
123
|
+
doFetch(false);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
window.addEventListener('focus', handleFocus);
|
|
128
|
+
|
|
129
|
+
return () => {
|
|
130
|
+
mounted = false;
|
|
131
|
+
window.removeEventListener('focus', handleFocus);
|
|
132
|
+
};
|
|
133
|
+
}, [config.authUrl, config.clientId, cacheKey, refreshCounter]);
|
|
134
|
+
|
|
135
|
+
return { providers, isLoading, error, refresh };
|
|
136
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { AuthConfig } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Configuration validation errors for better developer experience.
|
|
5
|
+
*/
|
|
6
|
+
export class AuthConfigError extends Error {
|
|
7
|
+
constructor(message: string) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'AuthConfigError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Validates AuthConfig and throws descriptive errors if invalid.
|
|
15
|
+
* Called at initialization time to fail fast with helpful messages.
|
|
16
|
+
* Only clientId is required — authUrl and appUrl can be auto-discovered.
|
|
17
|
+
*/
|
|
18
|
+
export function validateConfig(config: AuthConfig): void {
|
|
19
|
+
if (!config) {
|
|
20
|
+
throw new AuthConfigError(
|
|
21
|
+
'AuthProvider requires a config prop. ' +
|
|
22
|
+
'At minimum, provide { clientId: "your-app-id" }.',
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Required field
|
|
27
|
+
if (!config.clientId) {
|
|
28
|
+
throw new AuthConfigError(
|
|
29
|
+
'Missing required config: clientId. ' +
|
|
30
|
+
'This is the client ID registered with the auth service.',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// URL format validation (only if provided)
|
|
35
|
+
if (config.authUrl) {
|
|
36
|
+
try {
|
|
37
|
+
new URL(config.authUrl);
|
|
38
|
+
} catch {
|
|
39
|
+
throw new AuthConfigError(
|
|
40
|
+
`Invalid authUrl: "${config.authUrl}" is not a valid URL. ` +
|
|
41
|
+
'It should include the protocol (e.g., "https://auth.tony.codes").',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (config.appUrl) {
|
|
47
|
+
try {
|
|
48
|
+
new URL(config.appUrl);
|
|
49
|
+
} catch {
|
|
50
|
+
throw new AuthConfigError(
|
|
51
|
+
`Invalid appUrl: "${config.appUrl}" is not a valid URL. ` +
|
|
52
|
+
'It should include the protocol (e.g., "https://myapp.tony.codes").',
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (config.apiUrl) {
|
|
58
|
+
try {
|
|
59
|
+
new URL(config.apiUrl);
|
|
60
|
+
} catch {
|
|
61
|
+
throw new AuthConfigError(
|
|
62
|
+
`Invalid apiUrl: "${config.apiUrl}" is not a valid URL. ` +
|
|
63
|
+
'It should include the protocol (e.g., "https://api.myapp.tony.codes").',
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Common misconfiguration warnings (logged but don't throw)
|
|
69
|
+
if (typeof window !== 'undefined') {
|
|
70
|
+
const isProduction = !window.location.hostname.includes('test') &&
|
|
71
|
+
!window.location.hostname.includes('localhost');
|
|
72
|
+
|
|
73
|
+
if (isProduction && config.authUrl?.includes('localhost')) {
|
|
74
|
+
console.warn(
|
|
75
|
+
'[auth-react] Warning: authUrl contains "localhost" but app appears to be in production. ' +
|
|
76
|
+
'Make sure to update authUrl for production.',
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (config.authUrl?.endsWith('/')) {
|
|
81
|
+
console.warn(
|
|
82
|
+
'[auth-react] Warning: authUrl has a trailing slash. ' +
|
|
83
|
+
'This may cause double slashes in URLs.',
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (config.appUrl?.endsWith('/')) {
|
|
88
|
+
console.warn(
|
|
89
|
+
'[auth-react] Warning: appUrl has a trailing slash. ' +
|
|
90
|
+
'This may cause double slashes in URLs.',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|