@tonycodes/auth-react 1.0.1 → 1.1.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.
@@ -0,0 +1,331 @@
1
+ import { createContext, useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
2
+ import type { AuthConfig, ResolvedAuthConfig, AuthUser, AuthOrganization, AuthState } from './types.js';
3
+ import { validateConfig } from './validateConfig.js';
4
+
5
+ const DEFAULT_AUTH_URL = 'https://auth.tony.codes';
6
+
7
+ interface JWTPayload {
8
+ sub: string;
9
+ email: string;
10
+ name: string | null;
11
+ avatarUrl: string | null;
12
+ org: { id: string; name: string; slug: string; role: string } | null;
13
+ isSuperAdmin: boolean;
14
+ exp: number;
15
+ }
16
+
17
+ function decodeJWT(token: string): JWTPayload {
18
+ try {
19
+ const base64 = token.split('.')[1];
20
+ if (!base64) throw new Error('Invalid token structure');
21
+ const json = atob(base64.replace(/-/g, '+').replace(/_/g, '/'));
22
+ return JSON.parse(json);
23
+ } catch {
24
+ throw new Error('Failed to decode access token — the token may be malformed');
25
+ }
26
+ }
27
+
28
+ export const AuthContext = createContext<AuthState | null>(null);
29
+ export const AuthConfigContext = createContext<ResolvedAuthConfig | null>(null);
30
+
31
+ interface AuthProviderProps {
32
+ config: AuthConfig;
33
+ children: ReactNode;
34
+ }
35
+
36
+ /**
37
+ * Resolve config by discovering missing URLs from the auth service.
38
+ * 1. If appUrl provided explicitly → use it, skip discovery
39
+ * 2. Otherwise → fetch from /api/client-apps/:clientId/config
40
+ * 3. Fallback → use window.location.origin
41
+ */
42
+ async function resolveConfig(config: AuthConfig): Promise<ResolvedAuthConfig> {
43
+ const authUrl = config.authUrl || DEFAULT_AUTH_URL;
44
+ let appUrl = config.appUrl;
45
+ let apiUrl = config.apiUrl;
46
+
47
+ // If appUrl is already provided, skip discovery
48
+ if (!appUrl) {
49
+ try {
50
+ const res = await fetch(`${authUrl}/api/client-apps/${config.clientId}/config`);
51
+ if (res.ok) {
52
+ const data = await res.json();
53
+ appUrl = data.appUrl || undefined;
54
+ if (!apiUrl) apiUrl = data.apiUrl || undefined;
55
+ }
56
+ } catch {
57
+ // Discovery failed — use fallback
58
+ }
59
+ }
60
+
61
+ // Fallback to window.location.origin
62
+ if (!appUrl && typeof window !== 'undefined') {
63
+ appUrl = window.location.origin;
64
+ }
65
+
66
+ if (!appUrl) {
67
+ appUrl = authUrl; // Last resort
68
+ }
69
+
70
+ return {
71
+ clientId: config.clientId,
72
+ authUrl,
73
+ appUrl,
74
+ apiUrl: apiUrl || appUrl,
75
+ };
76
+ }
77
+
78
+ export function AuthProvider({ config, children }: AuthProviderProps) {
79
+ // Validate config on initialization (throws if invalid)
80
+ validateConfig(config);
81
+
82
+ const [resolved, setResolved] = useState<ResolvedAuthConfig | null>(() => {
83
+ // If all URLs are provided, resolve synchronously
84
+ const authUrl = config.authUrl || DEFAULT_AUTH_URL;
85
+ if (config.appUrl) {
86
+ return {
87
+ clientId: config.clientId,
88
+ authUrl,
89
+ appUrl: config.appUrl,
90
+ apiUrl: config.apiUrl || config.appUrl,
91
+ };
92
+ }
93
+ return null;
94
+ });
95
+
96
+ const [accessToken, setAccessToken] = useState<string | null>(null);
97
+ const [user, setUser] = useState<AuthUser | null>(null);
98
+ const [organization, setOrganization] = useState<AuthOrganization | null>(null);
99
+ const [organizations, setOrganizations] = useState<AuthOrganization[]>([]);
100
+ const [orgRole, setOrgRole] = useState<string>('member');
101
+ const [isSuperAdmin, setIsSuperAdmin] = useState(false);
102
+ const [isLoading, setIsLoading] = useState(true);
103
+ const [isLoggingOut, setIsLoggingOut] = useState(false);
104
+ const refreshTimerRef = useRef<ReturnType<typeof setTimeout>>();
105
+ const refreshLockRef = useRef(false);
106
+
107
+ // Discover config if needed
108
+ useEffect(() => {
109
+ if (resolved) return; // Already resolved synchronously
110
+ let mounted = true;
111
+ resolveConfig(config).then((r) => {
112
+ if (mounted) setResolved(r);
113
+ });
114
+ return () => { mounted = false; };
115
+ }, [config, resolved]);
116
+
117
+ const updateFromToken = useCallback((token: string) => {
118
+ const payload = decodeJWT(token);
119
+
120
+ setUser({
121
+ id: payload.sub,
122
+ email: payload.email,
123
+ name: payload.name || 'User',
124
+ role: payload.org?.role === 'owner' || payload.org?.role === 'admin' ? 'admin' : 'member',
125
+ imageUrl: payload.avatarUrl,
126
+ });
127
+
128
+ if (payload.org) {
129
+ setOrganization({
130
+ id: payload.org.id,
131
+ name: payload.org.name,
132
+ slug: payload.org.slug,
133
+ imageUrl: null,
134
+ });
135
+ setOrgRole(payload.org.role);
136
+ } else {
137
+ setOrganization(null);
138
+ setOrgRole('member');
139
+ }
140
+
141
+ setIsSuperAdmin(payload.isSuperAdmin);
142
+ setAccessToken(token);
143
+
144
+ return payload;
145
+ }, []);
146
+
147
+ const refreshToken = useCallback(async (): Promise<string | null> => {
148
+ if (!resolved) return null;
149
+ if (refreshLockRef.current) return null;
150
+ refreshLockRef.current = true;
151
+
152
+ try {
153
+ const res = await fetch(`${resolved.apiUrl}/auth/refresh`, {
154
+ method: 'POST',
155
+ credentials: 'include',
156
+ headers: { 'Content-Type': 'application/json' },
157
+ });
158
+
159
+ if (!res.ok) {
160
+ setAccessToken(null);
161
+ setUser(null);
162
+ setOrganization(null);
163
+ return null;
164
+ }
165
+
166
+ const data = await res.json();
167
+ const payload = updateFromToken(data.access_token);
168
+
169
+ // Schedule next refresh 1 minute before expiry
170
+ const expiresIn = payload.exp * 1000 - Date.now();
171
+ const refreshIn = Math.max(expiresIn - 60_000, 10_000);
172
+ if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
173
+ refreshTimerRef.current = setTimeout(() => {
174
+ refreshToken();
175
+ }, refreshIn);
176
+
177
+ return data.access_token;
178
+ } catch {
179
+ return null;
180
+ } finally {
181
+ refreshLockRef.current = false;
182
+ }
183
+ }, [resolved, updateFromToken]);
184
+
185
+ // Fetch user organizations list
186
+ const fetchOrganizations = useCallback(async (token: string) => {
187
+ if (!resolved) return;
188
+ try {
189
+ const res = await fetch(`${resolved.authUrl}/api/organizations`, {
190
+ headers: { Authorization: `Bearer ${token}` },
191
+ });
192
+ if (res.ok) {
193
+ const data = await res.json();
194
+ setOrganizations(data.organizations || []);
195
+ }
196
+ } catch {
197
+ // Silent failure — orgs list is supplementary
198
+ }
199
+ }, [resolved]);
200
+
201
+ // Initial auth check — try to refresh on mount (after config resolves)
202
+ useEffect(() => {
203
+ if (!resolved) return;
204
+ let mounted = true;
205
+
206
+ async function init() {
207
+ const token = await refreshToken();
208
+ if (mounted) {
209
+ if (token) {
210
+ await fetchOrganizations(token);
211
+ }
212
+ setIsLoading(false);
213
+ }
214
+ }
215
+
216
+ init();
217
+
218
+ return () => {
219
+ mounted = false;
220
+ if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
221
+ };
222
+ }, [resolved, refreshToken, fetchOrganizations]);
223
+
224
+ const login = useCallback((provider?: string) => {
225
+ if (!resolved) return;
226
+ const redirectUri = `${resolved.appUrl}/auth/callback`;
227
+ const state = btoa(JSON.stringify({ returnTo: window.location.pathname }));
228
+ const params = new URLSearchParams({
229
+ client_id: resolved.clientId,
230
+ redirect_uri: redirectUri,
231
+ state,
232
+ });
233
+ if (provider) params.set('provider', provider);
234
+ window.location.href = `${resolved.authUrl}/authorize?${params}`;
235
+ }, [resolved]);
236
+
237
+ const logout = useCallback(async () => {
238
+ if (!resolved) return;
239
+ setIsLoggingOut(true);
240
+ try {
241
+ await fetch(`${resolved.apiUrl}/auth/logout`, {
242
+ method: 'POST',
243
+ credentials: 'include',
244
+ });
245
+ } catch {
246
+ // Best-effort
247
+ }
248
+ if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
249
+ setAccessToken(null);
250
+ setUser(null);
251
+ setOrganization(null);
252
+ setOrganizations([]);
253
+ setIsLoggingOut(false);
254
+ }, [resolved]);
255
+
256
+ const switchOrganization = useCallback(
257
+ async (orgId: string) => {
258
+ if (!resolved) return;
259
+ try {
260
+ const res = await fetch(`${resolved.apiUrl}/auth/switch-org`, {
261
+ method: 'POST',
262
+ credentials: 'include',
263
+ headers: { 'Content-Type': 'application/json' },
264
+ body: JSON.stringify({ org_id: orgId }),
265
+ });
266
+
267
+ if (res.ok) {
268
+ const data = await res.json();
269
+ updateFromToken(data.access_token);
270
+ }
271
+ } catch {
272
+ // Silent failure
273
+ }
274
+ },
275
+ [resolved, updateFromToken],
276
+ );
277
+
278
+ const isAdmin = orgRole === 'admin' || orgRole === 'owner';
279
+ const isOwner = orgRole === 'owner';
280
+
281
+ const getAccessToken = useCallback(async (): Promise<string | null> => {
282
+ if (accessToken) {
283
+ try {
284
+ const payload = decodeJWT(accessToken);
285
+ if (payload.exp * 1000 - Date.now() > 60_000) {
286
+ return accessToken;
287
+ }
288
+ } catch {
289
+ // Fall through to refresh
290
+ }
291
+ }
292
+ return refreshToken();
293
+ }, [accessToken, refreshToken]);
294
+
295
+ const value: AuthState = {
296
+ isAuthenticated: !!accessToken && (config.requireOrg === false || !!organization),
297
+ isLoading: isLoading || !resolved,
298
+ user,
299
+ organization,
300
+ tenant: organization ? { id: organization.id, name: organization.name, slug: organization.slug } : null,
301
+ isAdmin,
302
+ isOwner,
303
+ orgRole,
304
+ isSuperAdmin,
305
+ isPlatformAdmin: isSuperAdmin,
306
+ accessToken,
307
+ getAccessToken,
308
+ login,
309
+ logout,
310
+ switchOrganization,
311
+ organizations,
312
+ isLoggingOut,
313
+ isLoggingIn: false,
314
+ loginError: null,
315
+ impersonating: false,
316
+ };
317
+
318
+ // Use resolved config for context, or a placeholder during discovery
319
+ const configValue: ResolvedAuthConfig = resolved || {
320
+ clientId: config.clientId,
321
+ authUrl: config.authUrl || DEFAULT_AUTH_URL,
322
+ appUrl: config.appUrl || '',
323
+ apiUrl: config.apiUrl || config.appUrl || '',
324
+ };
325
+
326
+ return (
327
+ <AuthConfigContext.Provider value={configValue}>
328
+ <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
329
+ </AuthConfigContext.Provider>
330
+ );
331
+ }
@@ -0,0 +1,75 @@
1
+ import { useState, useRef, useEffect } from 'react';
2
+ import { useAuth } from './useAuth.js';
3
+
4
+ interface OrganizationSwitcherProps {
5
+ className?: string;
6
+ }
7
+
8
+ export function OrganizationSwitcher({ className = '' }: OrganizationSwitcherProps) {
9
+ const { organization, organizations, switchOrganization } = useAuth();
10
+ const [isOpen, setIsOpen] = useState(false);
11
+ const dropdownRef = useRef<HTMLDivElement>(null);
12
+
13
+ // Close dropdown when clicking outside
14
+ useEffect(() => {
15
+ function handleClickOutside(event: MouseEvent) {
16
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
17
+ setIsOpen(false);
18
+ }
19
+ }
20
+ document.addEventListener('mousedown', handleClickOutside);
21
+ return () => document.removeEventListener('mousedown', handleClickOutside);
22
+ }, []);
23
+
24
+ if (organizations.length <= 1) {
25
+ // Single org — just display name
26
+ return (
27
+ <div className={className}>
28
+ <span>{organization?.name || 'No organization'}</span>
29
+ </div>
30
+ );
31
+ }
32
+
33
+ return (
34
+ <div ref={dropdownRef} className={`relative ${className}`}>
35
+ <button
36
+ onClick={() => setIsOpen(!isOpen)}
37
+ className="flex items-center gap-2 px-3 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
38
+ >
39
+ <span className="font-medium">{organization?.name || 'Select org'}</span>
40
+ <svg
41
+ className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
42
+ fill="none"
43
+ viewBox="0 0 24 24"
44
+ stroke="currentColor"
45
+ >
46
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
47
+ </svg>
48
+ </button>
49
+
50
+ {isOpen && (
51
+ <div className="absolute top-full left-0 mt-1 w-64 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50">
52
+ <div className="py-1">
53
+ {organizations.map((org) => (
54
+ <button
55
+ key={org.id}
56
+ onClick={() => {
57
+ switchOrganization(org.id);
58
+ setIsOpen(false);
59
+ }}
60
+ className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${
61
+ org.id === organization?.id
62
+ ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
63
+ : 'text-gray-700 dark:text-gray-300'
64
+ }`}
65
+ >
66
+ <div className="font-medium">{org.name}</div>
67
+ <div className="text-xs text-gray-400">{org.slug}</div>
68
+ </button>
69
+ ))}
70
+ </div>
71
+ </div>
72
+ )}
73
+ </div>
74
+ );
75
+ }
@@ -0,0 +1,202 @@
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
+ const GITHUB_ICON = (
15
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
16
+ <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" />
17
+ </svg>
18
+ );
19
+
20
+ const GOOGLE_ICON = (
21
+ <svg viewBox="0 0 24 24" width="20" height="20">
22
+ <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" />
23
+ <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" />
24
+ <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" />
25
+ <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" />
26
+ </svg>
27
+ );
28
+
29
+ const APPLE_ICON = (
30
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
31
+ <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" />
32
+ </svg>
33
+ );
34
+
35
+ const BITBUCKET_ICON = (
36
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
37
+ <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" />
38
+ </svg>
39
+ );
40
+
41
+ interface SignInFormProps {
42
+ /** List of provider IDs to show (for manual control). Overrides autoFetch when provided. */
43
+ providers?: SSOProvider[];
44
+ /** Whether to automatically fetch providers from the auth service. Defaults to true. */
45
+ autoFetch?: boolean;
46
+ className?: string;
47
+ }
48
+
49
+ export function SignInForm({ providers: propProviders, autoFetch = true, className }: SignInFormProps) {
50
+ const config = useAuthConfig();
51
+ const { providers: fetchedProviders, isLoading: providersLoading } = useProviders();
52
+ const [activeTab, setActiveTab] = useState<Mode>('signin');
53
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
54
+ const [returnTo, setReturnTo] = useState('/');
55
+
56
+ // Use prop providers if explicitly provided, otherwise use fetched providers
57
+ const enabledProviders: SSOProvider[] = propProviders
58
+ ? propProviders
59
+ : autoFetch
60
+ ? fetchedProviders.map((p) => p.id)
61
+ : ['github']; // fallback default
62
+
63
+ useEffect(() => {
64
+ const params = new URLSearchParams(window.location.search);
65
+ const errorParam = params.get('error');
66
+ const tabParam = params.get('tab') as Mode | null;
67
+ const returnToParam = params.get('returnTo');
68
+
69
+ if (returnToParam) setReturnTo(returnToParam);
70
+
71
+ if (errorParam) {
72
+ setErrorMessage(ERROR_MESSAGES[errorParam] || `Authentication error: ${errorParam}`);
73
+ if (errorParam === 'account_not_found') {
74
+ setActiveTab('signup');
75
+ }
76
+ }
77
+
78
+ if (tabParam === 'signin' || tabParam === 'signup') {
79
+ setActiveTab(tabParam);
80
+ }
81
+ }, []);
82
+
83
+ function handleOAuth(provider: string) {
84
+ const redirectUri = `${config.appUrl}/auth/callback`;
85
+ const state = btoa(JSON.stringify({ returnTo }));
86
+ const params = new URLSearchParams({
87
+ client_id: config.clientId,
88
+ redirect_uri: redirectUri,
89
+ state,
90
+ provider,
91
+ mode: activeTab,
92
+ });
93
+ window.location.href = `${config.authUrl}/authorize?${params}`;
94
+ }
95
+
96
+ function switchTab(tab: Mode) {
97
+ setActiveTab(tab);
98
+ setErrorMessage(null);
99
+ }
100
+
101
+ function tabClass(tab: Mode): string {
102
+ const isActive = activeTab === tab;
103
+ return [
104
+ 'flex-1 py-2 text-sm font-medium rounded-lg text-center cursor-pointer transition-colors',
105
+ isActive ? 'bg-white text-zinc-900 shadow-sm' : 'text-zinc-500 hover:text-zinc-700',
106
+ ].join(' ');
107
+ }
108
+
109
+ const buttonText = (provider: string) => {
110
+ const providerNames: Record<string, string> = {
111
+ github: 'GitHub',
112
+ google: 'Google',
113
+ apple: 'Apple',
114
+ bitbucket: 'Bitbucket',
115
+ };
116
+ const name = providerNames[provider] || provider;
117
+ return activeTab === 'signin' ? `Sign in with ${name}` : `Sign up with ${name}`;
118
+ };
119
+
120
+ return (
121
+ <div className={className || ''}>
122
+ {/* Tab toggle */}
123
+ <div className="flex gap-1 p-1 bg-zinc-100 rounded-xl mb-6">
124
+ <button type="button" className={tabClass('signin')} onClick={() => switchTab('signin')}>
125
+ Sign In
126
+ </button>
127
+ <button type="button" className={tabClass('signup')} onClick={() => switchTab('signup')}>
128
+ Sign Up
129
+ </button>
130
+ </div>
131
+
132
+ {/* Error message */}
133
+ {errorMessage && (
134
+ <div className="mb-4 p-3 rounded-lg bg-red-50 border border-red-200 text-red-700 text-sm">
135
+ {errorMessage}
136
+ </div>
137
+ )}
138
+
139
+ {/* Loading state */}
140
+ {autoFetch && !propProviders && providersLoading ? (
141
+ <div className="flex items-center justify-center py-4">
142
+ <div className="h-5 w-5 animate-spin rounded-full border-2 border-zinc-300 border-t-zinc-600" />
143
+ </div>
144
+ ) : (
145
+ <div className="space-y-3">
146
+ {/* GitHub */}
147
+ {enabledProviders.includes('github') && (
148
+ <button
149
+ type="button"
150
+ className="w-full flex items-center justify-center gap-3 px-6 py-3 bg-zinc-900 text-white font-medium rounded-xl hover:bg-zinc-800 transition-colors cursor-pointer"
151
+ onClick={() => handleOAuth('github')}
152
+ >
153
+ {GITHUB_ICON}
154
+ <span>{buttonText('github')}</span>
155
+ </button>
156
+ )}
157
+
158
+ {/* Google */}
159
+ {enabledProviders.includes('google') && (
160
+ <button
161
+ type="button"
162
+ className="w-full flex items-center justify-center gap-3 px-6 py-3 bg-white text-zinc-900 font-medium rounded-xl border border-zinc-300 hover:bg-zinc-50 transition-colors cursor-pointer"
163
+ onClick={() => handleOAuth('google')}
164
+ >
165
+ {GOOGLE_ICON}
166
+ <span>{buttonText('google')}</span>
167
+ </button>
168
+ )}
169
+
170
+ {/* Apple */}
171
+ {enabledProviders.includes('apple') && (
172
+ <button
173
+ type="button"
174
+ className="w-full flex items-center justify-center gap-3 px-6 py-3 bg-black text-white font-medium rounded-xl hover:bg-zinc-900 transition-colors cursor-pointer"
175
+ onClick={() => handleOAuth('apple')}
176
+ >
177
+ {APPLE_ICON}
178
+ <span>{buttonText('apple')}</span>
179
+ </button>
180
+ )}
181
+
182
+ {/* Bitbucket */}
183
+ {enabledProviders.includes('bitbucket') && (
184
+ <button
185
+ type="button"
186
+ className="w-full flex items-center justify-center gap-3 px-6 py-3 bg-blue-600 text-white font-medium rounded-xl hover:bg-blue-700 transition-colors cursor-pointer"
187
+ onClick={() => handleOAuth('bitbucket')}
188
+ >
189
+ {BITBUCKET_ICON}
190
+ <span>{buttonText('bitbucket')}</span>
191
+ </button>
192
+ )}
193
+ </div>
194
+ )}
195
+
196
+ {/* Terms */}
197
+ <p className="mt-6 text-center text-xs text-zinc-400">
198
+ By continuing, you agree to our terms of service.
199
+ </p>
200
+ </div>
201
+ );
202
+ }
@@ -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/style.css ADDED
@@ -0,0 +1 @@
1
+ @tailwind utilities;