@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,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 && !!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
|
+
}
|