@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.
- package/README.md +191 -377
- package/package.json +25 -12
- package/src/__tests__/auth-backend-contract.test.ts +84 -0
- package/src/__tests__/auth-client-oauth-register.test.ts +5 -8
- package/src/__tests__/auth-functions.test.ts +0 -1
- package/src/__tests__/session-user-groups.test.ts +45 -0
- package/src/__tests__/useauth-shape-contract.test.ts +0 -1
- package/src/client/__tests__/mock-backend.test.ts +141 -0
- package/src/client/__tests__/secure-session-storage.test.ts +75 -0
- package/src/client/__tests__/secure-token-storage.test.ts +69 -0
- package/src/client/__tests__/session-storage.test.ts +118 -0
- package/src/client/__tests__/token-auth-core.test.ts +190 -0
- package/src/client/auth-client.ts +71 -11
- package/src/client/auth-context.tsx +94 -17
- package/src/client/backend.ts +67 -0
- package/src/client/functions.ts +38 -57
- package/src/client/index.ts +15 -0
- package/src/client/mock-backend.ts +255 -0
- package/src/client/optional-secure-store.ts +21 -0
- package/src/client/secure-session-storage.native.ts +53 -0
- package/src/client/secure-session-storage.ts +20 -0
- package/src/client/secure-token-storage.native.ts +55 -0
- package/src/client/secure-token-storage.ts +32 -0
- package/src/client/session-storage.ts +142 -0
- package/src/client/token-auth-core.ts +190 -0
- package/src/client/token.ts +18 -0
- 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.test.tsx +136 -0
- package/src/hooks/index.ts +34 -0
- package/src/hooks/use-domain-claims.ts +144 -0
- package/src/hooks/use-invitations.ts +138 -0
- package/src/hooks/use-membership.ts +192 -0
- package/src/index.ts +43 -1
- package/src/server/index.ts +4 -0
- package/src/types/index.ts +5 -1
- package/src/utils/api-error.ts +54 -0
- package/src/utils/central-auth.ts +91 -0
- package/src/utils/index.ts +1 -0
- 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';
|
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
|
|
|
@@ -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
|
+
}
|