@startsimpli/auth 0.4.16 → 0.4.18
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/package.json +17 -14
- package/src/__tests__/auth-client-oauth-register.test.ts +5 -8
- package/src/__tests__/auth-functions.test.ts +0 -1
- package/src/__tests__/useauth-shape-contract.test.ts +0 -1
- package/src/client/__tests__/mock-backend.test.ts +3 -6
- package/src/client/auth-client.ts +59 -2
- package/src/client/auth-context.tsx +62 -7
- package/src/client/backend.ts +10 -1
- package/src/client/functions.ts +31 -5
- package/src/client/mock-backend.ts +0 -3
- package/src/client/use-auth.ts +6 -1
- package/src/components/forgot-password-form.tsx +97 -0
- package/src/components/index.ts +5 -1
- package/src/components/oauth-callback.tsx +5 -2
- package/src/components/reset-password-form.tsx +124 -0
- package/src/components/sign-in-form.tsx +125 -0
- package/src/components/signup-form.tsx +161 -0
- package/src/components/use-oauth-callback.ts +14 -2
- package/src/hooks/__tests__/use-domain-claims.test.tsx +95 -0
- package/src/hooks/__tests__/use-invitations.test.tsx +90 -0
- package/src/hooks/__tests__/use-membership-from-api.test.tsx +45 -0
- package/src/hooks/__tests__/use-membership.test.tsx +136 -0
- package/src/hooks/index.ts +39 -0
- package/src/hooks/use-domain-claims.ts +144 -0
- package/src/hooks/use-invitations.ts +138 -0
- package/src/hooks/use-membership-from-api.ts +99 -0
- package/src/hooks/use-membership.ts +192 -0
- package/src/index.ts +27 -0
- package/src/server/index.ts +4 -0
- package/src/types/index.ts +0 -1
- package/src/utils/central-auth.ts +91 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/validation.ts +10 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/auth",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.18",
|
|
4
4
|
"description": "Shared authentication package for StartSimpli Next.js apps",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -20,19 +20,10 @@
|
|
|
20
20
|
"publishConfig": {
|
|
21
21
|
"access": "public"
|
|
22
22
|
},
|
|
23
|
-
"scripts": {
|
|
24
|
-
"build": "tsup",
|
|
25
|
-
"dev": "tsup --watch",
|
|
26
|
-
"type-check": "tsc --noEmit",
|
|
27
|
-
"test": "vitest run",
|
|
28
|
-
"test:watch": "vitest",
|
|
29
|
-
"test:coverage": "vitest run --coverage",
|
|
30
|
-
"clean": "rm -rf dist"
|
|
31
|
-
},
|
|
32
23
|
"peerDependencies": {
|
|
24
|
+
"expo-secure-store": ">=12.0.0",
|
|
33
25
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
34
|
-
"react": "^18.0.0 || ^19.0.0"
|
|
35
|
-
"expo-secure-store": ">=12.0.0"
|
|
26
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
36
27
|
},
|
|
37
28
|
"peerDependenciesMeta": {
|
|
38
29
|
"next": {
|
|
@@ -43,11 +34,14 @@
|
|
|
43
34
|
}
|
|
44
35
|
},
|
|
45
36
|
"devDependencies": {
|
|
37
|
+
"@testing-library/react": "^16.3.2",
|
|
38
|
+
"@types/jsdom": "^21.1.7",
|
|
46
39
|
"@types/node": "^20.19.39",
|
|
47
40
|
"@types/react": "^19.2.14",
|
|
48
41
|
"@vitest/ui": "^4.1.5",
|
|
49
42
|
"jsdom": "^29.0.2",
|
|
50
|
-
"
|
|
43
|
+
"react": "^19.2.5",
|
|
44
|
+
"react-dom": "^19.2.5",
|
|
51
45
|
"tsup": "^8.5.1",
|
|
52
46
|
"typescript": "^6.0.3",
|
|
53
47
|
"vitest": "^4.1.5"
|
|
@@ -61,5 +55,14 @@
|
|
|
61
55
|
],
|
|
62
56
|
"dependencies": {
|
|
63
57
|
"zod": "^4.3.6"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build": "tsup",
|
|
61
|
+
"dev": "tsup --watch",
|
|
62
|
+
"type-check": "tsc --noEmit",
|
|
63
|
+
"test": "vitest run",
|
|
64
|
+
"test:watch": "vitest",
|
|
65
|
+
"test:coverage": "vitest run --coverage",
|
|
66
|
+
"clean": "rm -rf dist"
|
|
64
67
|
}
|
|
65
|
-
}
|
|
68
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* swap (bead q6vf). They are written to fail until the implementation lands.
|
|
6
6
|
*
|
|
7
7
|
* Scope of this file:
|
|
8
|
-
* - AuthClient.register({ email, password,
|
|
8
|
+
* - AuthClient.register({ email, password, name? })
|
|
9
9
|
* - AuthClient.signInWithGoogle(redirectTo?)
|
|
10
10
|
* - AuthClient.completeGoogleCallback(code, state)
|
|
11
11
|
*
|
|
@@ -46,7 +46,7 @@ describe('AuthClient.register (contract)', () => {
|
|
|
46
46
|
vi.restoreAllMocks()
|
|
47
47
|
})
|
|
48
48
|
|
|
49
|
-
it('POSTs { email, password,
|
|
49
|
+
it('POSTs { email, password, name } to /api/v1/auth/register/ and returns a Session', async () => {
|
|
50
50
|
const client = new AuthClient(makeConfig())
|
|
51
51
|
const mockFetch = vi.fn()
|
|
52
52
|
.mockResolvedValueOnce({
|
|
@@ -62,7 +62,6 @@ describe('AuthClient.register (contract)', () => {
|
|
|
62
62
|
const session = await client.register({
|
|
63
63
|
email: 'new@example.com',
|
|
64
64
|
password: 'SecurePass1!',
|
|
65
|
-
passwordConfirm: 'SecurePass1!',
|
|
66
65
|
name: 'New User',
|
|
67
66
|
})
|
|
68
67
|
|
|
@@ -74,7 +73,6 @@ describe('AuthClient.register (contract)', () => {
|
|
|
74
73
|
expect(body.email).toBe('new@example.com')
|
|
75
74
|
expect(body.password).toBe('SecurePass1!')
|
|
76
75
|
// Backend expects snake_case for confirmation field
|
|
77
|
-
expect(body.password_confirm).toBe('SecurePass1!')
|
|
78
76
|
expect(body.name).toBe('New User')
|
|
79
77
|
|
|
80
78
|
// Return shape
|
|
@@ -97,7 +95,7 @@ describe('AuthClient.register (contract)', () => {
|
|
|
97
95
|
}))
|
|
98
96
|
|
|
99
97
|
await expect(
|
|
100
|
-
client.register({ email: 'taken@example.com', password: 'x'
|
|
98
|
+
client.register({ email: 'taken@example.com', password: 'x' })
|
|
101
99
|
).rejects.toThrow(/already exists/i)
|
|
102
100
|
})
|
|
103
101
|
|
|
@@ -120,7 +118,6 @@ describe('AuthClient.register (contract)', () => {
|
|
|
120
118
|
const session = await client.register({
|
|
121
119
|
email: 'fetched@example.com',
|
|
122
120
|
password: 'SecurePass1!',
|
|
123
|
-
passwordConfirm: 'SecurePass1!',
|
|
124
121
|
})
|
|
125
122
|
|
|
126
123
|
expect(session.user.email).toBe('fetched@example.com')
|
|
@@ -134,7 +131,7 @@ describe('AuthClient.register (contract)', () => {
|
|
|
134
131
|
json: async () => ({ access: VALID_TOKEN, user: userPayload() }),
|
|
135
132
|
}))
|
|
136
133
|
|
|
137
|
-
await client.register({ email: 'new@example.com', password: 'x'
|
|
134
|
+
await client.register({ email: 'new@example.com', password: 'x' })
|
|
138
135
|
|
|
139
136
|
// The refresh timer is a private member — asserting via internal state
|
|
140
137
|
// is brittle, but the behavior we care about is observable: getSession
|
|
@@ -154,7 +151,7 @@ describe('AuthClient.register (contract)', () => {
|
|
|
154
151
|
const { getAccessToken, setAccessToken } = await import('../client/functions')
|
|
155
152
|
setAccessToken(null) // start clean
|
|
156
153
|
|
|
157
|
-
await client.register({ email: 'new@example.com', password: 'x'
|
|
154
|
+
await client.register({ email: 'new@example.com', password: 'x' })
|
|
158
155
|
|
|
159
156
|
// @startsimpli/api's FetchWrapper reads via this module-level getter.
|
|
160
157
|
// Without the mirror, useAuth().session would have a token but API calls
|
|
@@ -131,7 +131,6 @@ describe('CSRF not required for signin/register (endpoints are @csrf_exempt)', (
|
|
|
131
131
|
await registerAccount({
|
|
132
132
|
email: 'new@test.com',
|
|
133
133
|
password: 'securepassword',
|
|
134
|
-
passwordConfirm: 'securepassword',
|
|
135
134
|
});
|
|
136
135
|
|
|
137
136
|
// Should NOT call the CSRF endpoint
|
|
@@ -44,7 +44,7 @@ describe('createMockAuthBackend', () => {
|
|
|
44
44
|
|
|
45
45
|
it('registers a new account (unverified) and can sign in after', async () => {
|
|
46
46
|
const b = createMockAuthBackend();
|
|
47
|
-
const s = await b.register({ email: 'new@x.test', password: 'pw',
|
|
47
|
+
const s = await b.register({ email: 'new@x.test', password: 'pw', name: 'New Artist' });
|
|
48
48
|
expect(s.user.email).toBe('new@x.test');
|
|
49
49
|
expect(s.user.isEmailVerified).toBe(false);
|
|
50
50
|
expect(s.user.name).toBe('New Artist');
|
|
@@ -52,14 +52,11 @@ describe('createMockAuthBackend', () => {
|
|
|
52
52
|
expect((await b.login('new@x.test', 'pw')).user.email).toBe('new@x.test');
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
it('rejects duplicate registration
|
|
55
|
+
it('rejects duplicate registration', async () => {
|
|
56
56
|
const b = createMockAuthBackend({ accounts: seed() });
|
|
57
57
|
await expect(
|
|
58
|
-
b.register({ email: 'a@x.test', password: 'pw'
|
|
58
|
+
b.register({ email: 'a@x.test', password: 'pw' })
|
|
59
59
|
).rejects.toThrow(/already exists/i);
|
|
60
|
-
await expect(
|
|
61
|
-
b.register({ email: 'b@x.test', password: 'pw', passwordConfirm: 'nope' })
|
|
62
|
-
).rejects.toThrow(/do not match/i);
|
|
63
60
|
});
|
|
64
61
|
|
|
65
62
|
it('persists across a "restart" via shared storage', async () => {
|
|
@@ -132,7 +132,6 @@ export class AuthClient implements AuthBackend {
|
|
|
132
132
|
body: JSON.stringify({
|
|
133
133
|
email: payload.email,
|
|
134
134
|
password: payload.password,
|
|
135
|
-
password_confirm: payload.passwordConfirm,
|
|
136
135
|
name: payload.name,
|
|
137
136
|
first_name: payload.firstName ?? firstFromName ?? undefined,
|
|
138
137
|
last_name: payload.lastName ?? lastFromName ?? undefined,
|
|
@@ -191,8 +190,66 @@ export class AuthClient implements AuthBackend {
|
|
|
191
190
|
* Complete a Google OAuth callback: exchange the code + state for a session.
|
|
192
191
|
*/
|
|
193
192
|
async completeGoogleCallback(code: string, state: string): Promise<Session> {
|
|
193
|
+
return this.completeOAuthCallback('google', code, state);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Kick off Microsoft OAuth (Azure AD / Entra ID). Mirrors the Google flow;
|
|
198
|
+
* the backend returns `{ authorization_url }`.
|
|
199
|
+
*/
|
|
200
|
+
async signInWithMicrosoft(redirectTo?: string): Promise<string> {
|
|
201
|
+
const defaultRedirect =
|
|
202
|
+
typeof window !== 'undefined'
|
|
203
|
+
? `${window.location.origin}/oauth/microsoft/callback`
|
|
204
|
+
: '';
|
|
205
|
+
const redirectUri = redirectTo ?? defaultRedirect;
|
|
206
|
+
|
|
207
|
+
const response = await fetch(
|
|
208
|
+
`${this.config.apiBaseUrl}/api/v1/auth/oauth/microsoft/initiate/`,
|
|
209
|
+
{
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: { 'Content-Type': 'application/json' },
|
|
212
|
+
credentials: 'include',
|
|
213
|
+
body: JSON.stringify({ redirect_uri: redirectUri }),
|
|
214
|
+
}
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const data = await response.json().catch(() => ({} as Record<string, unknown>));
|
|
218
|
+
|
|
219
|
+
if (!response.ok) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
extractApiError(data as Record<string, unknown>, 'Failed to initiate Microsoft OAuth')
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Microsoft backend returns `authorization_url`; fall back to other casings.
|
|
226
|
+
const obj = data as { authorization_url?: string; auth_url?: string; authUrl?: string };
|
|
227
|
+
const url = obj.authorization_url ?? obj.auth_url ?? obj.authUrl;
|
|
228
|
+
if (!url) {
|
|
229
|
+
throw new Error('OAuth initiation succeeded but no authorization_url was returned');
|
|
230
|
+
}
|
|
231
|
+
return url;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Complete a Microsoft OAuth callback: exchange the code + state for a session.
|
|
236
|
+
*/
|
|
237
|
+
async completeMicrosoftCallback(code: string, state: string): Promise<Session> {
|
|
238
|
+
return this.completeOAuthCallback('microsoft', code, state);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Provider-agnostic OAuth callback completion. Both Google and Microsoft
|
|
243
|
+
* speak the same `GET /api/v1/auth/oauth/<provider>/callback/?code=&state=`
|
|
244
|
+
* shape, so we share the implementation and just swap the path segment.
|
|
245
|
+
*/
|
|
246
|
+
private async completeOAuthCallback(
|
|
247
|
+
provider: 'google' | 'microsoft',
|
|
248
|
+
code: string,
|
|
249
|
+
state: string,
|
|
250
|
+
): Promise<Session> {
|
|
194
251
|
const url = new URL(
|
|
195
|
-
`${this.config.apiBaseUrl}/api/v1/auth/oauth/
|
|
252
|
+
`${this.config.apiBaseUrl}/api/v1/auth/oauth/${provider}/callback/`
|
|
196
253
|
);
|
|
197
254
|
url.searchParams.set('code', code);
|
|
198
255
|
url.searchParams.set('state', state);
|
|
@@ -26,13 +26,14 @@ interface AuthContextValue extends AuthState {
|
|
|
26
26
|
register: (payload: {
|
|
27
27
|
email: string;
|
|
28
28
|
password: string;
|
|
29
|
-
passwordConfirm: string;
|
|
30
29
|
name?: string;
|
|
31
30
|
firstName?: string;
|
|
32
31
|
lastName?: string;
|
|
33
32
|
}) => Promise<void>;
|
|
34
33
|
signInWithGoogle: (redirectTo?: string) => Promise<string>;
|
|
35
34
|
completeGoogleCallback: (code: string, state: string) => Promise<void>;
|
|
35
|
+
signInWithMicrosoft: (redirectTo?: string) => Promise<string>;
|
|
36
|
+
completeMicrosoftCallback: (code: string, state: string) => Promise<void>;
|
|
36
37
|
/**
|
|
37
38
|
* Hydrate the provider with an externally-acquired session. Used by OAuth
|
|
38
39
|
* callback flows that run the token exchange via a component (OAuthCallback)
|
|
@@ -151,11 +152,37 @@ export function AuthProvider({
|
|
|
151
152
|
onSessionExpiredRef.current?.();
|
|
152
153
|
|
|
153
154
|
if (loginPathRef.current && typeof window !== 'undefined') {
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
155
|
+
const loginPath = loginPathRef.current;
|
|
156
|
+
const callbackParam = callbackParamRef.current;
|
|
157
|
+
const here = window.location.href;
|
|
158
|
+
const isAbsolute = /^https?:\/\//i.test(loginPath);
|
|
159
|
+
|
|
160
|
+
if (isAbsolute) {
|
|
161
|
+
// Full-URL loginPath (e.g. central auth host at
|
|
162
|
+
// https://auth.startsimpli.com/signin?app=vault). Avoid bouncing if
|
|
163
|
+
// we're already on that host+pathname, and preserve any pre-existing
|
|
164
|
+
// query params on the loginPath (e.g. ?app=vault) by appending the
|
|
165
|
+
// callback via URL/searchParams instead of naive string concat.
|
|
166
|
+
try {
|
|
167
|
+
const target = new URL(loginPath);
|
|
168
|
+
const current = new URL(window.location.href);
|
|
169
|
+
const isOnLogin =
|
|
170
|
+
current.host === target.host && current.pathname === target.pathname;
|
|
171
|
+
if (!isOnLogin) {
|
|
172
|
+
target.searchParams.set(callbackParam, here);
|
|
173
|
+
window.location.href = target.toString();
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
// Malformed loginPath — fall through to no-op rather than crash.
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
const path = window.location.pathname + window.location.search;
|
|
180
|
+
const isOnLogin = window.location.pathname.startsWith(loginPath);
|
|
181
|
+
if (!isOnLogin) {
|
|
182
|
+
const callback = encodeURIComponent(path);
|
|
183
|
+
const sep = loginPath.includes('?') ? '&' : '?';
|
|
184
|
+
window.location.href = `${loginPath}${sep}${callbackParam}=${callback}`;
|
|
185
|
+
}
|
|
159
186
|
}
|
|
160
187
|
}
|
|
161
188
|
};
|
|
@@ -230,7 +257,6 @@ export function AuthProvider({
|
|
|
230
257
|
async (payload: {
|
|
231
258
|
email: string;
|
|
232
259
|
password: string;
|
|
233
|
-
passwordConfirm: string;
|
|
234
260
|
name?: string;
|
|
235
261
|
firstName?: string;
|
|
236
262
|
lastName?: string;
|
|
@@ -269,6 +295,33 @@ export function AuthProvider({
|
|
|
269
295
|
[authClient]
|
|
270
296
|
);
|
|
271
297
|
|
|
298
|
+
const signInWithMicrosoft = useCallback(
|
|
299
|
+
async (redirectTo?: string) => {
|
|
300
|
+
if (!authClient.signInWithMicrosoft) {
|
|
301
|
+
throw new Error('signInWithMicrosoft is not supported by this auth backend');
|
|
302
|
+
}
|
|
303
|
+
return authClient.signInWithMicrosoft(redirectTo);
|
|
304
|
+
},
|
|
305
|
+
[authClient]
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const completeMicrosoftCallback = useCallback(
|
|
309
|
+
async (code: string, state: string) => {
|
|
310
|
+
if (!authClient.completeMicrosoftCallback) {
|
|
311
|
+
throw new Error('completeMicrosoftCallback is not supported by this auth backend');
|
|
312
|
+
}
|
|
313
|
+
setState((prev) => ({ ...prev, isLoading: true }));
|
|
314
|
+
try {
|
|
315
|
+
const session = await authClient.completeMicrosoftCallback(code, state);
|
|
316
|
+
setState({ session, isLoading: false, isAuthenticated: true });
|
|
317
|
+
} catch (error) {
|
|
318
|
+
setState((prev) => ({ ...prev, isLoading: false }));
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
[authClient]
|
|
323
|
+
);
|
|
324
|
+
|
|
272
325
|
const hydrateSession = useCallback(
|
|
273
326
|
(session: Session) => {
|
|
274
327
|
authClient.setSession(session);
|
|
@@ -286,6 +339,8 @@ export function AuthProvider({
|
|
|
286
339
|
register,
|
|
287
340
|
signInWithGoogle,
|
|
288
341
|
completeGoogleCallback,
|
|
342
|
+
signInWithMicrosoft,
|
|
343
|
+
completeMicrosoftCallback,
|
|
289
344
|
hydrateSession,
|
|
290
345
|
};
|
|
291
346
|
|
package/src/client/backend.ts
CHANGED
|
@@ -15,7 +15,6 @@ import type { Session, AuthUser } from '../types';
|
|
|
15
15
|
export interface RegisterPayload {
|
|
16
16
|
email: string;
|
|
17
17
|
password: string;
|
|
18
|
-
passwordConfirm: string;
|
|
19
18
|
name?: string;
|
|
20
19
|
firstName?: string;
|
|
21
20
|
lastName?: string;
|
|
@@ -46,6 +45,16 @@ export interface AuthBackend {
|
|
|
46
45
|
signInWithGoogle(redirectTo?: string): Promise<string>;
|
|
47
46
|
/** Complete an OAuth flow by exchanging the code+state for a session. */
|
|
48
47
|
completeGoogleCallback(code: string, state: string): Promise<Session>;
|
|
48
|
+
/**
|
|
49
|
+
* Begin a Microsoft OAuth flow; returns the authorization URL to redirect
|
|
50
|
+
* to. Optional — backends that don't speak Microsoft can omit it.
|
|
51
|
+
*/
|
|
52
|
+
signInWithMicrosoft?(redirectTo?: string): Promise<string>;
|
|
53
|
+
/**
|
|
54
|
+
* Complete a Microsoft OAuth callback. Optional — backends that don't speak
|
|
55
|
+
* Microsoft can omit it.
|
|
56
|
+
*/
|
|
57
|
+
completeMicrosoftCallback?(code: string, state: string): Promise<Session>;
|
|
49
58
|
/**
|
|
50
59
|
* Optional. Register the provider's session-expiry handler. The Django
|
|
51
60
|
* AuthClient wires this through AuthConfig.onSessionExpired instead, so it
|
package/src/client/functions.ts
CHANGED
|
@@ -74,6 +74,8 @@ const AUTH_PATHS = {
|
|
|
74
74
|
RESEND_VERIFICATION: `${API_BASE}/auth/resend-verification/`,
|
|
75
75
|
OAUTH_GOOGLE_INITIATE: `${API_BASE}/auth/oauth/google/initiate/`,
|
|
76
76
|
OAUTH_GOOGLE_CALLBACK: `${API_BASE}/auth/oauth/google/callback/`,
|
|
77
|
+
OAUTH_MICROSOFT_INITIATE: `${API_BASE}/auth/oauth/microsoft/initiate/`,
|
|
78
|
+
OAUTH_MICROSOFT_CALLBACK: `${API_BASE}/auth/oauth/microsoft/callback/`,
|
|
77
79
|
ME: `${API_BASE}/auth/me/`,
|
|
78
80
|
} as const;
|
|
79
81
|
|
|
@@ -297,7 +299,6 @@ export async function signInWithCredentials(email: string, password: string) {
|
|
|
297
299
|
export async function registerAccount(payload: {
|
|
298
300
|
email: string;
|
|
299
301
|
password: string;
|
|
300
|
-
passwordConfirm: string;
|
|
301
302
|
name?: string;
|
|
302
303
|
firstName?: string;
|
|
303
304
|
lastName?: string;
|
|
@@ -316,7 +317,6 @@ export async function registerAccount(payload: {
|
|
|
316
317
|
body: JSON.stringify({
|
|
317
318
|
email: payload.email,
|
|
318
319
|
password: payload.password,
|
|
319
|
-
password_confirm: payload.passwordConfirm,
|
|
320
320
|
first_name: payload.firstName ?? firstFromName ?? undefined,
|
|
321
321
|
last_name: payload.lastName ?? lastFromName ?? undefined,
|
|
322
322
|
}),
|
|
@@ -353,7 +353,6 @@ export async function requestPasswordReset(email: string): Promise<void> {
|
|
|
353
353
|
export async function resetPassword(payload: {
|
|
354
354
|
token: string;
|
|
355
355
|
password: string;
|
|
356
|
-
passwordConfirm: string;
|
|
357
356
|
email?: string;
|
|
358
357
|
}): Promise<void> {
|
|
359
358
|
const response = await fetchWithTimeout(resolveAuthUrl(AUTH_PATHS.RESET_PASSWORD), {
|
|
@@ -362,7 +361,6 @@ export async function resetPassword(payload: {
|
|
|
362
361
|
body: JSON.stringify({
|
|
363
362
|
token: payload.token,
|
|
364
363
|
password: payload.password,
|
|
365
|
-
password_confirm: payload.passwordConfirm,
|
|
366
364
|
...(payload.email ? { email: payload.email } : {}),
|
|
367
365
|
}),
|
|
368
366
|
});
|
|
@@ -427,9 +425,37 @@ export async function initiateGoogleOAuth(redirectUri: string): Promise<any> {
|
|
|
427
425
|
}
|
|
428
426
|
|
|
429
427
|
export async function completeGoogleOAuth(code: string, state: string) {
|
|
428
|
+
return _completeOAuthCallback(AUTH_PATHS.OAUTH_GOOGLE_CALLBACK, code, state);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
432
|
+
export async function initiateMicrosoftOAuth(redirectUri: string): Promise<any> {
|
|
433
|
+
const response = await fetch(resolveAuthUrl(AUTH_PATHS.OAUTH_MICROSOFT_INITIATE), {
|
|
434
|
+
method: 'POST',
|
|
435
|
+
headers: { 'Content-Type': 'application/json' },
|
|
436
|
+
credentials: 'include',
|
|
437
|
+
body: JSON.stringify({ redirect_uri: redirectUri }),
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
const data = await response.json().catch(() => ({}));
|
|
441
|
+
|
|
442
|
+
if (!response.ok) {
|
|
443
|
+
const d = data as Record<string, unknown>;
|
|
444
|
+
const message = (d?.detail || d?.error || 'Failed to initiate Microsoft OAuth') as string;
|
|
445
|
+
throw new Error(message);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return data;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export async function completeMicrosoftOAuth(code: string, state: string) {
|
|
452
|
+
return _completeOAuthCallback(AUTH_PATHS.OAUTH_MICROSOFT_CALLBACK, code, state);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function _completeOAuthCallback(callbackPath: string, code: string, state: string) {
|
|
430
456
|
const response = await fetchWithTimeout(
|
|
431
457
|
resolveAuthUrl(
|
|
432
|
-
`${
|
|
458
|
+
`${callbackPath}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
|
|
433
459
|
),
|
|
434
460
|
{ credentials: 'include' }
|
|
435
461
|
);
|
|
@@ -153,9 +153,6 @@ export function createMockAuthBackend(
|
|
|
153
153
|
if (accounts.has(key)) {
|
|
154
154
|
throw new Error('An account with this email already exists');
|
|
155
155
|
}
|
|
156
|
-
if (payload.password !== payload.passwordConfirm) {
|
|
157
|
-
throw new Error('Passwords do not match');
|
|
158
|
-
}
|
|
159
156
|
const user = createUser(payload);
|
|
160
157
|
accounts.set(key, { password: payload.password, user });
|
|
161
158
|
session = makeSession(user);
|
package/src/client/use-auth.ts
CHANGED
|
@@ -20,13 +20,14 @@ export interface UseAuthReturn {
|
|
|
20
20
|
register: (payload: {
|
|
21
21
|
email: string;
|
|
22
22
|
password: string;
|
|
23
|
-
passwordConfirm: string;
|
|
24
23
|
name?: string;
|
|
25
24
|
firstName?: string;
|
|
26
25
|
lastName?: string;
|
|
27
26
|
}) => Promise<void>;
|
|
28
27
|
signInWithGoogle: (redirectTo?: string) => Promise<string>;
|
|
29
28
|
completeGoogleCallback: (code: string, state: string) => Promise<void>;
|
|
29
|
+
signInWithMicrosoft: (redirectTo?: string) => Promise<string>;
|
|
30
|
+
completeMicrosoftCallback: (code: string, state: string) => Promise<void>;
|
|
30
31
|
hydrateSession: (session: Session) => void;
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -45,6 +46,8 @@ export function useAuth(): UseAuthReturn {
|
|
|
45
46
|
register,
|
|
46
47
|
signInWithGoogle,
|
|
47
48
|
completeGoogleCallback,
|
|
49
|
+
signInWithMicrosoft,
|
|
50
|
+
completeMicrosoftCallback,
|
|
48
51
|
hydrateSession,
|
|
49
52
|
} = useAuthContext();
|
|
50
53
|
|
|
@@ -60,6 +63,8 @@ export function useAuth(): UseAuthReturn {
|
|
|
60
63
|
register,
|
|
61
64
|
signInWithGoogle,
|
|
62
65
|
completeGoogleCallback,
|
|
66
|
+
signInWithMicrosoft,
|
|
67
|
+
completeMicrosoftCallback,
|
|
63
68
|
hydrateSession,
|
|
64
69
|
};
|
|
65
70
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ForgotPasswordForm — shared "send a reset link" form. startsim-j29.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useState } from 'react'
|
|
8
|
+
import { requestPasswordReset } from '../client/functions'
|
|
9
|
+
|
|
10
|
+
export interface ForgotPasswordFormProps {
|
|
11
|
+
onSuccess?: (email: string) => void
|
|
12
|
+
onSubmit?: (email: string) => Promise<void>
|
|
13
|
+
submitLabel?: string
|
|
14
|
+
submittingLabel?: string
|
|
15
|
+
successMessage?: string
|
|
16
|
+
classNames?: ForgotPasswordFormClassNames
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ForgotPasswordFormClassNames {
|
|
20
|
+
form?: string
|
|
21
|
+
fieldRow?: string
|
|
22
|
+
label?: string
|
|
23
|
+
input?: string
|
|
24
|
+
errorText?: string
|
|
25
|
+
submitButton?: string
|
|
26
|
+
successText?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULTS: Required<ForgotPasswordFormClassNames> = {
|
|
30
|
+
form: 'space-y-4',
|
|
31
|
+
fieldRow: '',
|
|
32
|
+
label: 'block text-sm font-medium text-gray-700 mb-1',
|
|
33
|
+
input:
|
|
34
|
+
'w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500',
|
|
35
|
+
errorText: 'text-sm text-red-600',
|
|
36
|
+
submitButton:
|
|
37
|
+
'w-full rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-50',
|
|
38
|
+
successText: 'text-sm text-green-700',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function ForgotPasswordForm({
|
|
42
|
+
onSuccess,
|
|
43
|
+
onSubmit,
|
|
44
|
+
submitLabel = 'Send reset link',
|
|
45
|
+
submittingLabel = 'Sending…',
|
|
46
|
+
successMessage = 'If an account with that email exists, you’ll get a reset link shortly.',
|
|
47
|
+
classNames,
|
|
48
|
+
}: ForgotPasswordFormProps) {
|
|
49
|
+
const [email, setEmail] = useState('')
|
|
50
|
+
const [error, setError] = useState('')
|
|
51
|
+
const [success, setSuccess] = useState(false)
|
|
52
|
+
const [submitting, setSubmitting] = useState(false)
|
|
53
|
+
const cls = { ...DEFAULTS, ...(classNames ?? {}) }
|
|
54
|
+
|
|
55
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
56
|
+
e.preventDefault()
|
|
57
|
+
setError('')
|
|
58
|
+
setSuccess(false)
|
|
59
|
+
setSubmitting(true)
|
|
60
|
+
try {
|
|
61
|
+
if (onSubmit) await onSubmit(email)
|
|
62
|
+
else await requestPasswordReset(email)
|
|
63
|
+
setSuccess(true)
|
|
64
|
+
onSuccess?.(email)
|
|
65
|
+
} catch (err) {
|
|
66
|
+
setError(err instanceof Error ? err.message : 'Could not send reset link')
|
|
67
|
+
} finally {
|
|
68
|
+
setSubmitting(false)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (success) {
|
|
73
|
+
return <p className={cls.successText}>{successMessage}</p>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<form onSubmit={handleSubmit} className={cls.form}>
|
|
78
|
+
<div className={cls.fieldRow}>
|
|
79
|
+
<label htmlFor="forgot-email" className={cls.label}>Email</label>
|
|
80
|
+
<input
|
|
81
|
+
id="forgot-email"
|
|
82
|
+
type="email"
|
|
83
|
+
value={email}
|
|
84
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
85
|
+
autoComplete="email"
|
|
86
|
+
required
|
|
87
|
+
className={cls.input}
|
|
88
|
+
disabled={submitting}
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
{error && <p className={cls.errorText}>{error}</p>}
|
|
92
|
+
<button type="submit" disabled={submitting} className={cls.submitButton}>
|
|
93
|
+
{submitting ? submittingLabel : submitLabel}
|
|
94
|
+
</button>
|
|
95
|
+
</form>
|
|
96
|
+
)
|
|
97
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export { GoogleSignInButton, type GoogleSignInButtonProps } from './google-sign-in-button'
|
|
2
2
|
export { OAuthCallback, type OAuthCallbackProps } from './oauth-callback'
|
|
3
|
-
export { useOAuthCallback, type UseOAuthCallbackOptions, type UseOAuthCallbackReturn, type OAuthCallbackResult } from './use-oauth-callback'
|
|
3
|
+
export { useOAuthCallback, type UseOAuthCallbackOptions, type UseOAuthCallbackReturn, type OAuthCallbackResult, type OAuthProvider } from './use-oauth-callback'
|
|
4
4
|
export { OAuthConnectionCard, type OAuthConnectionCardProps } from './oauth-connection-card'
|
|
5
|
+
export { SignupForm, type SignupFormProps, type SignupPayload, type SignupFormClassNames } from './signup-form'
|
|
6
|
+
export { SignInForm, type SignInFormProps, type SignInPayload, type SignInFormClassNames } from './sign-in-form'
|
|
7
|
+
export { ResetPasswordForm, type ResetPasswordFormProps, type ResetPasswordFormClassNames } from './reset-password-form'
|
|
8
|
+
export { ForgotPasswordForm, type ForgotPasswordFormProps, type ForgotPasswordFormClassNames } from './forgot-password-form'
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import type
|
|
4
|
-
import { useOAuthCallback, type OAuthCallbackResult } from './use-oauth-callback'
|
|
3
|
+
import { useOAuthCallback, type OAuthCallbackResult, type OAuthProvider } from './use-oauth-callback'
|
|
5
4
|
|
|
6
5
|
export interface OAuthCallbackProps {
|
|
7
6
|
/** Code from OAuth redirect URL params */
|
|
@@ -18,6 +17,8 @@ export interface OAuthCallbackProps {
|
|
|
18
17
|
loadingContent?: React.ReactNode
|
|
19
18
|
/** Custom error content renderer */
|
|
20
19
|
renderError?: (error: string, signInPath: string) => React.ReactNode
|
|
20
|
+
/** OAuth provider to complete the callback against. Default: 'google'. */
|
|
21
|
+
provider?: OAuthProvider
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
/**
|
|
@@ -45,12 +46,14 @@ export function OAuthCallback({
|
|
|
45
46
|
signInPath = '/auth/signin',
|
|
46
47
|
loadingContent,
|
|
47
48
|
renderError,
|
|
49
|
+
provider,
|
|
48
50
|
}: OAuthCallbackProps) {
|
|
49
51
|
const { error, isProcessing, redirectTo } = useOAuthCallback(
|
|
50
52
|
{ code, state },
|
|
51
53
|
{
|
|
52
54
|
onSuccess: (result) => onSuccess({ ...result, redirectTo }),
|
|
53
55
|
onError,
|
|
56
|
+
provider,
|
|
54
57
|
},
|
|
55
58
|
)
|
|
56
59
|
|