@startsimpli/auth 0.4.15 → 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.
Files changed (48) hide show
  1. package/README.md +191 -377
  2. package/package.json +25 -12
  3. package/src/__tests__/auth-backend-contract.test.ts +84 -0
  4. package/src/__tests__/auth-client-oauth-register.test.ts +5 -8
  5. package/src/__tests__/auth-functions.test.ts +0 -1
  6. package/src/__tests__/session-user-groups.test.ts +45 -0
  7. package/src/__tests__/useauth-shape-contract.test.ts +0 -1
  8. package/src/client/__tests__/mock-backend.test.ts +141 -0
  9. package/src/client/__tests__/secure-session-storage.test.ts +75 -0
  10. package/src/client/__tests__/secure-token-storage.test.ts +69 -0
  11. package/src/client/__tests__/session-storage.test.ts +118 -0
  12. package/src/client/__tests__/token-auth-core.test.ts +190 -0
  13. package/src/client/auth-client.ts +71 -11
  14. package/src/client/auth-context.tsx +94 -17
  15. package/src/client/backend.ts +67 -0
  16. package/src/client/functions.ts +38 -57
  17. package/src/client/index.ts +15 -0
  18. package/src/client/mock-backend.ts +255 -0
  19. package/src/client/optional-secure-store.ts +21 -0
  20. package/src/client/secure-session-storage.native.ts +53 -0
  21. package/src/client/secure-session-storage.ts +20 -0
  22. package/src/client/secure-token-storage.native.ts +55 -0
  23. package/src/client/secure-token-storage.ts +32 -0
  24. package/src/client/session-storage.ts +142 -0
  25. package/src/client/token-auth-core.ts +190 -0
  26. package/src/client/token.ts +18 -0
  27. package/src/client/use-auth.ts +6 -1
  28. package/src/components/forgot-password-form.tsx +97 -0
  29. package/src/components/index.ts +5 -1
  30. package/src/components/oauth-callback.tsx +5 -2
  31. package/src/components/reset-password-form.tsx +124 -0
  32. package/src/components/sign-in-form.tsx +125 -0
  33. package/src/components/signup-form.tsx +161 -0
  34. package/src/components/use-oauth-callback.ts +14 -2
  35. package/src/hooks/__tests__/use-domain-claims.test.tsx +95 -0
  36. package/src/hooks/__tests__/use-invitations.test.tsx +90 -0
  37. package/src/hooks/__tests__/use-membership.test.tsx +136 -0
  38. package/src/hooks/index.ts +34 -0
  39. package/src/hooks/use-domain-claims.ts +144 -0
  40. package/src/hooks/use-invitations.ts +138 -0
  41. package/src/hooks/use-membership.ts +192 -0
  42. package/src/index.ts +43 -1
  43. package/src/server/index.ts +4 -0
  44. package/src/types/index.ts +5 -1
  45. package/src/utils/api-error.ts +54 -0
  46. package/src/utils/central-auth.ts +91 -0
  47. package/src/utils/index.ts +1 -0
  48. package/src/utils/validation.ts +10 -21
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Async persistence for a serialized {@link Session}.
3
+ *
4
+ * Backends that own their session (the mock backend, offline-first clients)
5
+ * use this to survive reloads/app-restarts. The Django web client persists via
6
+ * httpOnly cookies instead and does not need it.
7
+ *
8
+ * Implementations here are platform-neutral (memory, Web Storage). The
9
+ * secure-store (iOS Keychain / Android Keystore) implementation lives in
10
+ * secure-session-storage.native.ts and is resolved by Metro on native builds.
11
+ */
12
+
13
+ import type { Session } from '../types';
14
+
15
+ export interface SessionStorage {
16
+ /** Return the persisted session, or null if none / unreadable. */
17
+ load(): Promise<Session | null>;
18
+ /** Persist the session. */
19
+ save(session: Session): Promise<void>;
20
+ /** Remove any persisted session. */
21
+ clear(): Promise<void>;
22
+ }
23
+
24
+ /** Default storage key. */
25
+ export const SESSION_STORAGE_KEY = 'startsimpli.auth.session';
26
+
27
+ function isSession(value: unknown): value is Session {
28
+ if (!value || typeof value !== 'object') return false;
29
+ const v = value as Record<string, unknown>;
30
+ return (
31
+ typeof v.accessToken === 'string' &&
32
+ typeof v.expiresAt === 'number' &&
33
+ !!v.user &&
34
+ typeof v.user === 'object'
35
+ );
36
+ }
37
+
38
+ /**
39
+ * In-memory storage. Lost on reload — the safe default for SSR and tests, and
40
+ * a graceful fallback when no Web Storage is available.
41
+ */
42
+ export function createMemorySessionStorage(): SessionStorage {
43
+ let current: Session | null = null;
44
+ return {
45
+ async load() {
46
+ return current;
47
+ },
48
+ async save(session) {
49
+ current = session;
50
+ },
51
+ async clear() {
52
+ current = null;
53
+ },
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Web Storage (localStorage by default) backed storage. Persists across
59
+ * reloads. Falls back to in-memory when no Storage is available (SSR).
60
+ */
61
+ export function createWebSessionStorage(opts: {
62
+ key?: string;
63
+ storage?: Storage;
64
+ } = {}): SessionStorage {
65
+ const key = opts.key ?? SESSION_STORAGE_KEY;
66
+ const resolveStorage = (): Storage | null => {
67
+ if (opts.storage) return opts.storage;
68
+ try {
69
+ const s = (globalThis as unknown as { localStorage?: Storage }).localStorage;
70
+ return s && typeof s.getItem === 'function' ? s : null;
71
+ } catch {
72
+ return null;
73
+ }
74
+ };
75
+
76
+ const memoryFallback = createMemorySessionStorage();
77
+
78
+ return {
79
+ async load() {
80
+ const storage = resolveStorage();
81
+ if (!storage) return memoryFallback.load();
82
+ try {
83
+ const raw = storage.getItem(key);
84
+ if (!raw) return null;
85
+ const parsed = JSON.parse(raw);
86
+ return isSession(parsed) ? parsed : null;
87
+ } catch {
88
+ return null;
89
+ }
90
+ },
91
+ async save(session) {
92
+ const storage = resolveStorage();
93
+ if (!storage) return memoryFallback.save(session);
94
+ try {
95
+ storage.setItem(key, JSON.stringify(session));
96
+ } catch {
97
+ /* quota / serialization failure — non-fatal for a demo session */
98
+ }
99
+ },
100
+ async clear() {
101
+ const storage = resolveStorage();
102
+ if (!storage) return memoryFallback.clear();
103
+ try {
104
+ storage.removeItem(key);
105
+ } catch {
106
+ /* ignore */
107
+ }
108
+ },
109
+ };
110
+ }
111
+
112
+ /**
113
+ * "Remember me" wrapper. When `shouldRemember()` is true at save time, the
114
+ * session is written to `persistent` storage (survives restarts); otherwise it
115
+ * is kept only in `transient` (in-memory) storage and `persistent` is cleared,
116
+ * so a restart starts unauthenticated. `load` always reads `persistent`, so a
117
+ * session is restored only if the last save was a "remembered" one.
118
+ */
119
+ export function createRememberAwareSessionStorage(
120
+ persistent: SessionStorage,
121
+ shouldRemember: () => boolean,
122
+ transient: SessionStorage = createMemorySessionStorage()
123
+ ): SessionStorage {
124
+ return {
125
+ load() {
126
+ return persistent.load();
127
+ },
128
+ async save(session) {
129
+ if (shouldRemember()) {
130
+ await transient.clear();
131
+ await persistent.save(session);
132
+ } else {
133
+ await persistent.clear();
134
+ await transient.save(session);
135
+ }
136
+ },
137
+ async clear() {
138
+ await persistent.clear();
139
+ await transient.clear();
140
+ },
141
+ };
142
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Platform-neutral, token-mode auth client.
3
+ *
4
+ * Mirrors the session lifecycle of the web cookie-based AuthClient (login /
5
+ * refreshToken / logout / getCurrentUser) but works WITHOUT cookies, so it runs
6
+ * on React Native (and any non-browser client). It opts into the backend's
7
+ * token mode via `X-Auth-Mode: token`, carries the refresh token through a
8
+ * pluggable TokenStorage, and sends the access token as a Bearer header.
9
+ *
10
+ * Backend contract (start-simpli-api, shipped by claude-mac):
11
+ * POST /api/v1/auth/token/ X-Auth-Mode: token -> { access, refresh }
12
+ * POST /api/v1/auth/token/refresh/ body { refresh } -> { access, refresh }
13
+ * POST /api/v1/auth/logout/ Bearer + body { refresh } (blacklists refresh)
14
+ * GET /api/v1/auth/me/ Bearer
15
+ *
16
+ * DOM-free by construction: no cookies, no window/document. The only platform
17
+ * detail — where the refresh token is persisted — is injected as TokenStorage.
18
+ */
19
+
20
+ import type { AuthUser, Session } from '../types';
21
+ import { getTokenExpiresAt } from '../utils/token';
22
+ import { extractApiError } from '../utils/api-error';
23
+
24
+ /** Persistence for the refresh token. Web uses cookies (and never needs this);
25
+ * native provides a SecureStore-backed implementation. */
26
+ export interface TokenStorage {
27
+ getRefreshToken(): Promise<string | null>;
28
+ setRefreshToken(token: string): Promise<void>;
29
+ clear(): Promise<void>;
30
+ }
31
+
32
+ /** In-memory TokenStorage — used in tests and as a non-persistent fallback. */
33
+ export class InMemoryTokenStorage implements TokenStorage {
34
+ private refresh: string | null = null;
35
+ async getRefreshToken(): Promise<string | null> {
36
+ return this.refresh;
37
+ }
38
+ async setRefreshToken(token: string): Promise<void> {
39
+ this.refresh = token;
40
+ }
41
+ async clear(): Promise<void> {
42
+ this.refresh = null;
43
+ }
44
+ }
45
+
46
+ export interface TokenAuthConfig {
47
+ apiBaseUrl: string;
48
+ storage: TokenStorage;
49
+ /** Injectable for tests; defaults to the global fetch. */
50
+ fetch?: typeof fetch;
51
+ }
52
+
53
+ function normalizeUser(raw: unknown): AuthUser | null {
54
+ if (!raw || typeof raw !== 'object') return null;
55
+ const obj = raw as Record<string, unknown>;
56
+ const p = (obj.user && typeof obj.user === 'object' ? obj.user : obj) as Record<string, unknown>;
57
+ if (!p.id || !p.email) return null;
58
+ return {
59
+ id: String(p.id),
60
+ email: String(p.email),
61
+ firstName: (p.first_name ?? p.firstName ?? '') as string,
62
+ lastName: (p.last_name ?? p.lastName ?? '') as string,
63
+ isEmailVerified: Boolean(p.is_email_verified ?? p.isEmailVerified ?? false),
64
+ createdAt: (p.created_at ?? p.createdAt ?? '') as string,
65
+ updatedAt: (p.updated_at ?? p.updatedAt ?? '') as string,
66
+ name: (p.name as string | null | undefined) ?? null,
67
+ };
68
+ }
69
+
70
+ export class TokenAuthClient {
71
+ private apiBaseUrl: string;
72
+ private storage: TokenStorage;
73
+ private fetchImpl: typeof fetch;
74
+ private accessToken: string | null = null;
75
+ private session: Session | null = null;
76
+
77
+ constructor(config: TokenAuthConfig) {
78
+ this.apiBaseUrl = config.apiBaseUrl;
79
+ this.storage = config.storage;
80
+ this.fetchImpl = config.fetch ?? (globalThis.fetch.bind(globalThis) as typeof fetch);
81
+ }
82
+
83
+ getAccessToken(): string | null {
84
+ return this.accessToken;
85
+ }
86
+
87
+ getSession(): Session | null {
88
+ return this.session;
89
+ }
90
+
91
+ async login(email: string, password: string): Promise<Session> {
92
+ const response = await this.fetchImpl(`${this.apiBaseUrl}/api/v1/auth/token/`, {
93
+ method: 'POST',
94
+ headers: { 'Content-Type': 'application/json', 'X-Auth-Mode': 'token' },
95
+ body: JSON.stringify({ email, password }),
96
+ });
97
+
98
+ if (!response.ok) {
99
+ const data = await response.json().catch(() => ({}) as Record<string, unknown>);
100
+ throw new Error(extractApiError(data as Record<string, unknown>, 'Login failed'));
101
+ }
102
+
103
+ const data = (await response.json()) as Record<string, unknown>;
104
+ const access = data.access as string;
105
+ const expiresAt = getTokenExpiresAt(access);
106
+ if (!expiresAt) {
107
+ throw new Error('Invalid token received');
108
+ }
109
+
110
+ await this.storage.setRefreshToken(data.refresh as string);
111
+ this.accessToken = access;
112
+
113
+ let user = data.user ? normalizeUser(data.user) : null;
114
+ if (!user) {
115
+ user = await this.getCurrentUser(access);
116
+ }
117
+
118
+ this.session = { user, accessToken: access, expiresAt };
119
+ return this.session;
120
+ }
121
+
122
+ async refreshToken(): Promise<string> {
123
+ const refresh = await this.storage.getRefreshToken();
124
+ if (!refresh) {
125
+ throw new Error('No refresh token available');
126
+ }
127
+
128
+ const response = await this.fetchImpl(`${this.apiBaseUrl}/api/v1/auth/token/refresh/`, {
129
+ method: 'POST',
130
+ headers: { 'Content-Type': 'application/json' },
131
+ body: JSON.stringify({ refresh }),
132
+ });
133
+
134
+ if (!response.ok) {
135
+ await this.storage.clear();
136
+ this.accessToken = null;
137
+ this.session = null;
138
+ const data = await response.json().catch(() => ({}) as Record<string, unknown>);
139
+ throw new Error(extractApiError(data as Record<string, unknown>, 'Token refresh failed'));
140
+ }
141
+
142
+ const data = (await response.json()) as Record<string, unknown>;
143
+ const access = data.access as string;
144
+ // The backend rotates the refresh token in token mode; persist the new one.
145
+ if (typeof data.refresh === 'string') {
146
+ await this.storage.setRefreshToken(data.refresh);
147
+ }
148
+ this.accessToken = access;
149
+ return access;
150
+ }
151
+
152
+ async logout(): Promise<void> {
153
+ const refresh = await this.storage.getRefreshToken();
154
+ try {
155
+ await this.fetchImpl(`${this.apiBaseUrl}/api/v1/auth/logout/`, {
156
+ method: 'POST',
157
+ headers: {
158
+ 'Content-Type': 'application/json',
159
+ ...(this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : {}),
160
+ },
161
+ body: JSON.stringify({ refresh: refresh ?? undefined }),
162
+ });
163
+ } catch {
164
+ // Network failure shouldn't strand a logged-out user with local tokens.
165
+ } finally {
166
+ await this.storage.clear();
167
+ this.accessToken = null;
168
+ this.session = null;
169
+ }
170
+ }
171
+
172
+ async getCurrentUser(accessToken: string): Promise<AuthUser> {
173
+ const response = await this.fetchImpl(`${this.apiBaseUrl}/api/v1/auth/me/`, {
174
+ method: 'GET',
175
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` },
176
+ });
177
+
178
+ if (!response.ok) {
179
+ const data = await response.json().catch(() => ({}) as Record<string, unknown>);
180
+ throw new Error(extractApiError(data as Record<string, unknown>, 'Failed to fetch user'));
181
+ }
182
+
183
+ const data = (await response.json()) as Record<string, unknown>;
184
+ const user = normalizeUser(data);
185
+ if (!user) {
186
+ throw new Error('Invalid user response');
187
+ }
188
+ return user;
189
+ }
190
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @startsimpli/auth/token — platform-neutral, cookie-free auth entry.
3
+ *
4
+ * The RN-safe surface for token-mode auth: a TokenAuthClient that carries the
5
+ * refresh token through a TokenStorage instead of cookies. SecureTokenStorage
6
+ * resolves to an expo-secure-store impl on React Native and to a throwing stub
7
+ * on the web (where the cookie-based AuthClient should be used instead).
8
+ *
9
+ * Kept separate from '@startsimpli/auth/client' because that barrel pulls in the
10
+ * DOM/cookie-bound web client (functions, AuthProvider).
11
+ */
12
+ export {
13
+ TokenAuthClient,
14
+ InMemoryTokenStorage,
15
+ type TokenStorage,
16
+ type TokenAuthConfig,
17
+ } from './token-auth-core';
18
+ export { SecureTokenStorage, REFRESH_TOKEN_KEY } from './secure-token-storage';
@@ -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
 
@@ -0,0 +1,124 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * ResetPasswordForm — shared password-reset form.
5
+ *
6
+ * The reset TOKEN comes from a query string the consumer reads (next/router
7
+ * differs across app versions); the form just takes it as a prop.
8
+ *
9
+ * Same one-edit-everywhere principle as SignupForm: future password-policy
10
+ * changes happen HERE, not in each app's /auth/reset-password/page.tsx.
11
+ * startsim-j29.
12
+ */
13
+
14
+ import { useState } from 'react'
15
+ import { resetPassword } from '../client/functions'
16
+
17
+ export interface ResetPasswordFormProps {
18
+ /** The reset token from the URL (e.g. ?token=…). Consumer reads it from
19
+ * useSearchParams / useRouter and hands it in. */
20
+ token: string
21
+ /** Optional email if the API requires it alongside the token. */
22
+ email?: string
23
+ /** Called after a successful reset. Apps typically router.replace('/login'). */
24
+ onSuccess?: () => void
25
+ /** Optional override for the submit handler. Defaults to
26
+ * resetPassword({ token, password, email? }) from @startsimpli/auth. */
27
+ onSubmit?: (payload: { token: string; password: string; email?: string }) => Promise<void>
28
+ /** Rendered in place of the form when no token is present. */
29
+ invalidTokenMessage?: React.ReactNode
30
+ minPasswordLength?: number
31
+ submitLabel?: string
32
+ submittingLabel?: string
33
+ classNames?: ResetPasswordFormClassNames
34
+ }
35
+
36
+ export interface ResetPasswordFormClassNames {
37
+ form?: string
38
+ fieldRow?: string
39
+ label?: string
40
+ input?: string
41
+ errorText?: string
42
+ submitButton?: string
43
+ invalidTokenText?: string
44
+ }
45
+
46
+ const DEFAULTS: Required<ResetPasswordFormClassNames> = {
47
+ form: 'space-y-4',
48
+ fieldRow: '',
49
+ label: 'block text-sm font-medium text-gray-700 mb-1',
50
+ input:
51
+ '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',
52
+ errorText: 'text-sm text-red-600',
53
+ submitButton:
54
+ 'w-full rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-50',
55
+ invalidTokenText: 'text-sm text-red-600',
56
+ }
57
+
58
+ export function ResetPasswordForm({
59
+ token,
60
+ email,
61
+ onSuccess,
62
+ onSubmit,
63
+ invalidTokenMessage,
64
+ minPasswordLength = 8,
65
+ submitLabel = 'Reset password',
66
+ submittingLabel = 'Saving…',
67
+ classNames,
68
+ }: ResetPasswordFormProps) {
69
+ const [password, setPassword] = useState('')
70
+ const [error, setError] = useState('')
71
+ const [submitting, setSubmitting] = useState(false)
72
+ const cls = { ...DEFAULTS, ...(classNames ?? {}) }
73
+
74
+ if (!token) {
75
+ return (
76
+ <p className={cls.invalidTokenText}>
77
+ {invalidTokenMessage ?? 'This reset link is invalid or has expired.'}
78
+ </p>
79
+ )
80
+ }
81
+
82
+ async function handleSubmit(e: React.FormEvent) {
83
+ e.preventDefault()
84
+ setError('')
85
+ if (password.length < minPasswordLength) {
86
+ setError(`Password must be at least ${minPasswordLength} characters`)
87
+ return
88
+ }
89
+ setSubmitting(true)
90
+ try {
91
+ const payload = { token, password, ...(email ? { email } : {}) }
92
+ if (onSubmit) await onSubmit(payload)
93
+ else await resetPassword(payload)
94
+ onSuccess?.()
95
+ } catch (err) {
96
+ setError(err instanceof Error ? err.message : 'Could not reset password')
97
+ } finally {
98
+ setSubmitting(false)
99
+ }
100
+ }
101
+
102
+ return (
103
+ <form onSubmit={handleSubmit} className={cls.form}>
104
+ <div className={cls.fieldRow}>
105
+ <label htmlFor="reset-password" className={cls.label}>New password</label>
106
+ <input
107
+ id="reset-password"
108
+ type="password"
109
+ value={password}
110
+ onChange={(e) => setPassword(e.target.value)}
111
+ autoComplete="new-password"
112
+ required
113
+ minLength={minPasswordLength}
114
+ className={cls.input}
115
+ disabled={submitting}
116
+ />
117
+ </div>
118
+ {error && <p className={cls.errorText}>{error}</p>}
119
+ <button type="submit" disabled={submitting} className={cls.submitButton}>
120
+ {submitting ? submittingLabel : submitLabel}
121
+ </button>
122
+ </form>
123
+ )
124
+ }