@startsimpli/auth 0.4.16 → 0.4.17

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/auth",
3
- "version": "0.4.16",
3
+ "version": "0.4.17",
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
- "@types/jsdom": "^21.1.7",
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, passwordConfirm, name? })
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, password_confirm, name } to /api/v1/auth/register/ and returns a Session', async () => {
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', passwordConfirm: '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', passwordConfirm: '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', passwordConfirm: '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
@@ -31,7 +31,6 @@ describe('useAuth() shape contract (target API)', () => {
31
31
  const _: (payload: {
32
32
  email: string
33
33
  password: string
34
- passwordConfirm: string
35
34
  name?: string
36
35
  firstName?: string
37
36
  lastName?: string
@@ -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', passwordConfirm: 'pw', name: 'New Artist' });
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 and mismatched passwords', async () => {
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', passwordConfirm: '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/google/callback/`
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 here = window.location.pathname + window.location.search;
155
- const isOnLogin = window.location.pathname.startsWith(loginPathRef.current);
156
- if (!isOnLogin) {
157
- const callback = encodeURIComponent(here);
158
- window.location.href = `${loginPathRef.current}?${callbackParamRef.current}=${callback}`;
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
 
@@ -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
@@ -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
- `${AUTH_PATHS.OAUTH_GOOGLE_CALLBACK}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
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);
@@ -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
+ }
@@ -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 { AuthUser } from '../types'
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