@startsimpli/auth 0.1.0
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 +484 -0
- package/package.json +43 -0
- package/src/__tests__/permissions.test.ts +42 -0
- package/src/__tests__/token.test.ts +97 -0
- package/src/client/auth-client.ts +265 -0
- package/src/client/auth-context.tsx +153 -0
- package/src/client/functions.ts +424 -0
- package/src/client/index.ts +5 -0
- package/src/client/use-auth.ts +45 -0
- package/src/client/use-permissions.ts +82 -0
- package/src/email/index.ts +136 -0
- package/src/index.ts +41 -0
- package/src/server/guards.ts +113 -0
- package/src/server/index.ts +20 -0
- package/src/server/middleware.ts +106 -0
- package/src/server/session.ts +115 -0
- package/src/types/index.ts +142 -0
- package/src/utils/cookies.ts +86 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/token.ts +89 -0
- package/src/validation/index.ts +34 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Functional auth API for Django backend
|
|
3
|
+
*
|
|
4
|
+
* Stateless functions for authentication flows: sign in, register, OAuth, token refresh, etc.
|
|
5
|
+
* Uses fetch (browser/Node) and getCsrfToken from shared utils.
|
|
6
|
+
* No Next.js dependency.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { getCsrfToken } from '../utils/cookies';
|
|
10
|
+
|
|
11
|
+
// --- Types ---
|
|
12
|
+
|
|
13
|
+
export interface AuthUser {
|
|
14
|
+
id: string;
|
|
15
|
+
email: string;
|
|
16
|
+
name?: string | null;
|
|
17
|
+
firstName?: string | null;
|
|
18
|
+
lastName?: string | null;
|
|
19
|
+
groups?: string[];
|
|
20
|
+
permissions?: string[];
|
|
21
|
+
isActive?: boolean;
|
|
22
|
+
isEmailVerified?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// --- Endpoint paths (Django backend defaults) ---
|
|
26
|
+
|
|
27
|
+
const API_BASE = '/api/v1';
|
|
28
|
+
|
|
29
|
+
const AUTH_PATHS = {
|
|
30
|
+
TOKEN: `${API_BASE}/auth/token/`,
|
|
31
|
+
TOKEN_REFRESH: `${API_BASE}/auth/token/refresh/`,
|
|
32
|
+
REGISTER: `${API_BASE}/auth/register/`,
|
|
33
|
+
LOGOUT: `${API_BASE}/auth/logout/`,
|
|
34
|
+
FORGOT_PASSWORD: `${API_BASE}/auth/forgot-password/`,
|
|
35
|
+
RESET_PASSWORD: `${API_BASE}/auth/reset-password/`,
|
|
36
|
+
VERIFY_EMAIL: `${API_BASE}/auth/verify-email/`,
|
|
37
|
+
RESEND_VERIFICATION: `${API_BASE}/auth/resend-verification/`,
|
|
38
|
+
OAUTH_GOOGLE_INITIATE: `${API_BASE}/auth/oauth/google/initiate/`,
|
|
39
|
+
OAUTH_GOOGLE_CALLBACK: `${API_BASE}/auth/oauth/google/callback/`,
|
|
40
|
+
ME: `${API_BASE}/auth/me/`,
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
// --- Base URL resolution ---
|
|
44
|
+
|
|
45
|
+
const AUTH_BASE_URL =
|
|
46
|
+
(typeof process !== 'undefined' &&
|
|
47
|
+
(process.env.NEXT_PUBLIC_API_URL || process.env.NEXT_PUBLIC_API_BASE_URL)) ||
|
|
48
|
+
'';
|
|
49
|
+
const AUTH_BASE_IS_ABSOLUTE = /^https?:\/\//i.test(AUTH_BASE_URL);
|
|
50
|
+
|
|
51
|
+
function isAbsoluteUrl(url: string) {
|
|
52
|
+
return /^https?:\/\//i.test(url);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function resolveAuthUrl(path: string): string {
|
|
56
|
+
if (isAbsoluteUrl(path)) return path;
|
|
57
|
+
|
|
58
|
+
const normalized = path.startsWith('/') ? path : `/${path}`;
|
|
59
|
+
if (AUTH_BASE_IS_ABSOLUTE) {
|
|
60
|
+
return new URL(normalized, AUTH_BASE_URL).toString();
|
|
61
|
+
}
|
|
62
|
+
if (normalized.startsWith('/api/')) return normalized;
|
|
63
|
+
if (!AUTH_BASE_URL) return normalized;
|
|
64
|
+
|
|
65
|
+
if (AUTH_BASE_URL.endsWith('/') && normalized.startsWith('/')) {
|
|
66
|
+
return `${AUTH_BASE_URL.slice(0, -1)}${normalized}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return `${AUTH_BASE_URL}${normalized}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- In-memory token store ---
|
|
73
|
+
|
|
74
|
+
let accessToken: string | null = null;
|
|
75
|
+
|
|
76
|
+
export function getAccessToken(): string | null {
|
|
77
|
+
return accessToken;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function setAccessToken(token: string | null): void {
|
|
81
|
+
accessToken = token;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// --- Internal helpers ---
|
|
85
|
+
|
|
86
|
+
function normalizeUser(raw: unknown): AuthUser | null {
|
|
87
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
88
|
+
const obj = raw as Record<string, unknown>;
|
|
89
|
+
const payload = (obj.user && typeof obj.user === 'object' ? obj.user : obj) as Record<string, unknown>;
|
|
90
|
+
if (!payload?.id || !payload?.email) return null;
|
|
91
|
+
|
|
92
|
+
const firstName = (payload.first_name ?? payload.firstName ?? null) as string | null;
|
|
93
|
+
const lastName = (payload.last_name ?? payload.lastName ?? null) as string | null;
|
|
94
|
+
const name =
|
|
95
|
+
(payload.name as string | null) ??
|
|
96
|
+
([firstName, lastName].filter(Boolean).join(' ') || null);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
id: payload.id as string,
|
|
100
|
+
email: payload.email as string,
|
|
101
|
+
name,
|
|
102
|
+
firstName,
|
|
103
|
+
lastName,
|
|
104
|
+
groups: Array.isArray(payload.groups) ? (payload.groups as string[]) : [],
|
|
105
|
+
permissions: Array.isArray(payload.permissions) ? (payload.permissions as string[]) : [],
|
|
106
|
+
isActive: (payload.isActive ?? payload.is_active) as boolean | undefined,
|
|
107
|
+
isEmailVerified: (payload.isEmailVerified ?? payload.is_email_verified) as boolean | undefined,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseAuthResponse(data: unknown): { access?: string; user?: AuthUser } {
|
|
112
|
+
if (!data || typeof data !== 'object') return {};
|
|
113
|
+
const obj = data as Record<string, unknown>;
|
|
114
|
+
const payload = (obj.data && typeof obj.data === 'object' ? obj.data : obj) as Record<string, unknown>;
|
|
115
|
+
const access = (payload.access || payload.access_token || payload.token) as string | undefined;
|
|
116
|
+
const userRaw = payload.user || payload.me || payload.profile || payload;
|
|
117
|
+
const user = normalizeUser(userRaw);
|
|
118
|
+
return { access, user: user ?? undefined };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- Auth functions ---
|
|
122
|
+
|
|
123
|
+
export async function signInWithCredentials(email: string, password: string) {
|
|
124
|
+
const response = await fetch(resolveAuthUrl(AUTH_PATHS.TOKEN), {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
headers: { 'Content-Type': 'application/json' },
|
|
127
|
+
credentials: 'include',
|
|
128
|
+
body: JSON.stringify({ email, password }),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const data = await response.json().catch(() => ({}));
|
|
132
|
+
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
const d = data as Record<string, unknown>;
|
|
135
|
+
if (d?.error === 'ACCOUNT_LOCKED' && d?.locked_until) {
|
|
136
|
+
throw new Error(`Account locked until ${new Date(d.locked_until as string).toLocaleTimeString()}`);
|
|
137
|
+
}
|
|
138
|
+
const message = (d?.detail || d?.error || 'Authentication failed') as string;
|
|
139
|
+
throw new Error(message);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const parsed = parseAuthResponse(data);
|
|
143
|
+
if (parsed.access) {
|
|
144
|
+
setAccessToken(parsed.access);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return parsed;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function registerAccount(payload: {
|
|
151
|
+
email: string;
|
|
152
|
+
password: string;
|
|
153
|
+
passwordConfirm: string;
|
|
154
|
+
name?: string;
|
|
155
|
+
firstName?: string;
|
|
156
|
+
lastName?: string;
|
|
157
|
+
}) {
|
|
158
|
+
const rawName = payload.name?.trim() || '';
|
|
159
|
+
const [firstFromName, ...rest] = rawName ? rawName.split(/\s+/) : [];
|
|
160
|
+
const lastFromName = rest.length ? rest.join(' ') : undefined;
|
|
161
|
+
|
|
162
|
+
const response = await fetch(resolveAuthUrl(AUTH_PATHS.REGISTER), {
|
|
163
|
+
method: 'POST',
|
|
164
|
+
headers: { 'Content-Type': 'application/json' },
|
|
165
|
+
credentials: 'include',
|
|
166
|
+
body: JSON.stringify({
|
|
167
|
+
email: payload.email,
|
|
168
|
+
password: payload.password,
|
|
169
|
+
password_confirm: payload.passwordConfirm,
|
|
170
|
+
first_name: payload.firstName ?? firstFromName ?? undefined,
|
|
171
|
+
last_name: payload.lastName ?? lastFromName ?? undefined,
|
|
172
|
+
}),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const data = await response.json().catch(() => ({}));
|
|
176
|
+
|
|
177
|
+
if (!response.ok) {
|
|
178
|
+
const d = data as Record<string, unknown>;
|
|
179
|
+
const message = (d?.detail || d?.error || 'Registration failed') as string;
|
|
180
|
+
throw new Error(message);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const parsed = parseAuthResponse(data);
|
|
184
|
+
if (parsed.access) {
|
|
185
|
+
setAccessToken(parsed.access);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return parsed;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function requestPasswordReset(email: string): Promise<void> {
|
|
192
|
+
const response = await fetch(resolveAuthUrl(AUTH_PATHS.FORGOT_PASSWORD), {
|
|
193
|
+
method: 'POST',
|
|
194
|
+
headers: { 'Content-Type': 'application/json' },
|
|
195
|
+
body: JSON.stringify({ email }),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
if (!response.ok) {
|
|
199
|
+
const data = await response.json().catch(() => ({}));
|
|
200
|
+
const d = data as Record<string, unknown>;
|
|
201
|
+
const message = (d?.detail || d?.error || 'Failed to send reset link') as string;
|
|
202
|
+
throw new Error(message);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function resetPassword(payload: {
|
|
207
|
+
token: string;
|
|
208
|
+
password: string;
|
|
209
|
+
passwordConfirm: string;
|
|
210
|
+
email?: string;
|
|
211
|
+
}): Promise<void> {
|
|
212
|
+
const response = await fetch(resolveAuthUrl(AUTH_PATHS.RESET_PASSWORD), {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
headers: { 'Content-Type': 'application/json' },
|
|
215
|
+
body: JSON.stringify({
|
|
216
|
+
token: payload.token,
|
|
217
|
+
password: payload.password,
|
|
218
|
+
password_confirm: payload.passwordConfirm,
|
|
219
|
+
...(payload.email ? { email: payload.email } : {}),
|
|
220
|
+
}),
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
if (!response.ok) {
|
|
224
|
+
const data = await response.json().catch(() => ({}));
|
|
225
|
+
const d = data as Record<string, unknown>;
|
|
226
|
+
const message = (d?.detail || d?.error || 'Failed to reset password') as string;
|
|
227
|
+
throw new Error(message);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function verifyEmail(token: string): Promise<void> {
|
|
232
|
+
const response = await fetch(resolveAuthUrl(AUTH_PATHS.VERIFY_EMAIL), {
|
|
233
|
+
method: 'POST',
|
|
234
|
+
headers: { 'Content-Type': 'application/json' },
|
|
235
|
+
body: JSON.stringify({ token }),
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (!response.ok) {
|
|
239
|
+
const data = await response.json().catch(() => ({}));
|
|
240
|
+
const d = data as Record<string, unknown>;
|
|
241
|
+
const message = (d?.detail || d?.error || 'Failed to verify email') as string;
|
|
242
|
+
throw new Error(message);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export async function resendVerification(access?: string | null): Promise<void> {
|
|
247
|
+
const response = await fetch(resolveAuthUrl(AUTH_PATHS.RESEND_VERIFICATION), {
|
|
248
|
+
method: 'POST',
|
|
249
|
+
headers: {
|
|
250
|
+
'Content-Type': 'application/json',
|
|
251
|
+
...(access ? { Authorization: `Bearer ${access}` } : {}),
|
|
252
|
+
},
|
|
253
|
+
credentials: 'include',
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
if (!response.ok) {
|
|
257
|
+
const data = await response.json().catch(() => ({}));
|
|
258
|
+
const d = data as Record<string, unknown>;
|
|
259
|
+
const message = (d?.detail || d?.error || 'Failed to resend verification email') as string;
|
|
260
|
+
throw new Error(message);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
265
|
+
export async function initiateGoogleOAuth(redirectUri: string): Promise<any> {
|
|
266
|
+
const response = await fetch(resolveAuthUrl(AUTH_PATHS.OAUTH_GOOGLE_INITIATE), {
|
|
267
|
+
method: 'POST',
|
|
268
|
+
headers: { 'Content-Type': 'application/json' },
|
|
269
|
+
credentials: 'include',
|
|
270
|
+
body: JSON.stringify({ redirect_uri: redirectUri }),
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const data = await response.json().catch(() => ({}));
|
|
274
|
+
|
|
275
|
+
if (!response.ok) {
|
|
276
|
+
const d = data as Record<string, unknown>;
|
|
277
|
+
const message = (d?.detail || d?.error || 'Failed to initiate Google OAuth') as string;
|
|
278
|
+
throw new Error(message);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return data;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function completeGoogleOAuth(code: string, state: string) {
|
|
285
|
+
const response = await fetch(
|
|
286
|
+
resolveAuthUrl(
|
|
287
|
+
`${AUTH_PATHS.OAUTH_GOOGLE_CALLBACK}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
|
|
288
|
+
),
|
|
289
|
+
{ credentials: 'include' }
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const data = await response.json().catch(() => ({}));
|
|
293
|
+
|
|
294
|
+
if (!response.ok) {
|
|
295
|
+
const d = data as Record<string, unknown>;
|
|
296
|
+
const message = (d?.detail || d?.error || 'OAuth authentication failed') as string;
|
|
297
|
+
throw new Error(message);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const parsed = parseAuthResponse(data);
|
|
301
|
+
if (parsed.access) {
|
|
302
|
+
setAccessToken(parsed.access);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return parsed;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function refreshAccessToken(): Promise<string | null> {
|
|
309
|
+
const csrfToken = getCsrfToken();
|
|
310
|
+
if (!csrfToken) return null;
|
|
311
|
+
|
|
312
|
+
const response = await fetch(resolveAuthUrl(AUTH_PATHS.TOKEN_REFRESH), {
|
|
313
|
+
method: 'POST',
|
|
314
|
+
headers: {
|
|
315
|
+
'Content-Type': 'application/json',
|
|
316
|
+
'X-CSRFToken': csrfToken,
|
|
317
|
+
},
|
|
318
|
+
credentials: 'include',
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const data = await response.json().catch(() => ({}));
|
|
322
|
+
|
|
323
|
+
if (!response.ok) {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const parsed = parseAuthResponse(data);
|
|
328
|
+
if (parsed.access) {
|
|
329
|
+
setAccessToken(parsed.access);
|
|
330
|
+
return parsed.access;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export async function getMe(): Promise<AuthUser | null> {
|
|
337
|
+
const token = getAccessToken();
|
|
338
|
+
if (!token) return null;
|
|
339
|
+
|
|
340
|
+
const response = await fetch(resolveAuthUrl(AUTH_PATHS.ME), {
|
|
341
|
+
headers: {
|
|
342
|
+
Authorization: `Bearer ${token}`,
|
|
343
|
+
},
|
|
344
|
+
credentials: 'include',
|
|
345
|
+
cache: 'no-store',
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
if (!response.ok) {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const data = await response.json().catch(() => ({}));
|
|
353
|
+
const obj = data as Record<string, unknown>;
|
|
354
|
+
const payload = obj.data && typeof obj.data === 'object' ? obj.data : obj;
|
|
355
|
+
const userRaw = (payload as Record<string, unknown>).user || payload;
|
|
356
|
+
return normalizeUser(userRaw);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export async function signOut(): Promise<void> {
|
|
360
|
+
const csrfToken = getCsrfToken();
|
|
361
|
+
const token = getAccessToken();
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
await fetch(resolveAuthUrl(AUTH_PATHS.LOGOUT), {
|
|
365
|
+
method: 'POST',
|
|
366
|
+
headers: {
|
|
367
|
+
'Content-Type': 'application/json',
|
|
368
|
+
...(csrfToken ? { 'X-CSRFToken': csrfToken } : {}),
|
|
369
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
370
|
+
},
|
|
371
|
+
credentials: 'include',
|
|
372
|
+
});
|
|
373
|
+
} finally {
|
|
374
|
+
setAccessToken(null);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export async function authFetch(
|
|
379
|
+
input: RequestInfo | URL,
|
|
380
|
+
init: RequestInit = {}
|
|
381
|
+
): Promise<Response> {
|
|
382
|
+
const token = getAccessToken();
|
|
383
|
+
const headers = new Headers(init.headers || {});
|
|
384
|
+
|
|
385
|
+
if (token && !headers.has('Authorization')) {
|
|
386
|
+
headers.set('Authorization', `Bearer ${token}`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const resolvedInput = typeof input === 'string' ? resolveAuthUrl(input) : input;
|
|
390
|
+
|
|
391
|
+
const response = await fetch(resolvedInput, {
|
|
392
|
+
...init,
|
|
393
|
+
headers,
|
|
394
|
+
credentials: init.credentials ?? 'include',
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
if (response.status === 401) {
|
|
398
|
+
const refreshed = await refreshAccessToken();
|
|
399
|
+
if (refreshed) {
|
|
400
|
+
const retryHeaders = new Headers(init.headers || {});
|
|
401
|
+
retryHeaders.set('Authorization', `Bearer ${refreshed}`);
|
|
402
|
+
|
|
403
|
+
return fetch(resolvedInput, {
|
|
404
|
+
...init,
|
|
405
|
+
headers: retryHeaders,
|
|
406
|
+
credentials: init.credentials ?? 'include',
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return response;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function hasPermission(user: AuthUser | null | undefined, permission: string): boolean {
|
|
415
|
+
if (!user) return false;
|
|
416
|
+
if (!user.permissions || user.permissions.length === 0) return false;
|
|
417
|
+
return user.permissions.includes(permission);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function hasGroup(user: AuthUser | null | undefined, group: string): boolean {
|
|
421
|
+
if (!user) return false;
|
|
422
|
+
if (!user.groups || user.groups.length === 0) return false;
|
|
423
|
+
return user.groups.includes(group);
|
|
424
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { AuthClient } from './auth-client';
|
|
2
|
+
export { AuthProvider, useAuthContext } from './auth-context';
|
|
3
|
+
export { useAuth, type UseAuthReturn } from './use-auth';
|
|
4
|
+
export { usePermissions, type UsePermissionsReturn } from './use-permissions';
|
|
5
|
+
export * from './functions';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React hook for authentication
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
'use client';
|
|
6
|
+
|
|
7
|
+
import { useAuthContext } from './auth-context';
|
|
8
|
+
import type { AuthUser, Session } from '../types';
|
|
9
|
+
|
|
10
|
+
export interface UseAuthReturn {
|
|
11
|
+
user: AuthUser | null;
|
|
12
|
+
session: Session | null;
|
|
13
|
+
isLoading: boolean;
|
|
14
|
+
isAuthenticated: boolean;
|
|
15
|
+
login: (email: string, password: string) => Promise<void>;
|
|
16
|
+
logout: () => Promise<void>;
|
|
17
|
+
refreshUser: () => Promise<void>;
|
|
18
|
+
getAccessToken: () => Promise<string | null>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Hook to access authentication state and methods
|
|
23
|
+
*/
|
|
24
|
+
export function useAuth(): UseAuthReturn {
|
|
25
|
+
const {
|
|
26
|
+
session,
|
|
27
|
+
isLoading,
|
|
28
|
+
isAuthenticated,
|
|
29
|
+
login,
|
|
30
|
+
logout,
|
|
31
|
+
refreshUser,
|
|
32
|
+
getAccessToken,
|
|
33
|
+
} = useAuthContext();
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
user: session?.user || null,
|
|
37
|
+
session,
|
|
38
|
+
isLoading,
|
|
39
|
+
isAuthenticated,
|
|
40
|
+
login,
|
|
41
|
+
logout,
|
|
42
|
+
refreshUser,
|
|
43
|
+
getAccessToken,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React hook for permission checks
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
'use client';
|
|
6
|
+
|
|
7
|
+
import { useMemo } from 'react';
|
|
8
|
+
import { useAuth } from './use-auth';
|
|
9
|
+
import type { CompanyRole } from '../types';
|
|
10
|
+
import { hasRolePermission } from '../types';
|
|
11
|
+
|
|
12
|
+
export interface UsePermissionsReturn {
|
|
13
|
+
hasRole: (requiredRole: CompanyRole, companyId?: string) => boolean;
|
|
14
|
+
isOwner: (companyId?: string) => boolean;
|
|
15
|
+
isAdmin: (companyId?: string) => boolean;
|
|
16
|
+
canEdit: (companyId?: string) => boolean;
|
|
17
|
+
canView: (companyId?: string) => boolean;
|
|
18
|
+
currentRole: CompanyRole | null;
|
|
19
|
+
currentCompanyId: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Hook to check user permissions
|
|
24
|
+
*/
|
|
25
|
+
export function usePermissions(): UsePermissionsReturn {
|
|
26
|
+
const { user } = useAuth();
|
|
27
|
+
|
|
28
|
+
const currentCompanyId = user?.currentCompanyId || null;
|
|
29
|
+
|
|
30
|
+
const currentCompany = useMemo(() => {
|
|
31
|
+
if (!user?.companies || !currentCompanyId) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return user.companies.find((c) => c.id === currentCompanyId);
|
|
35
|
+
}, [user, currentCompanyId]);
|
|
36
|
+
|
|
37
|
+
const currentRole = currentCompany?.role || null;
|
|
38
|
+
|
|
39
|
+
const hasRole = (requiredRole: CompanyRole, companyId?: string): boolean => {
|
|
40
|
+
if (!user?.companies) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const targetCompanyId = companyId || currentCompanyId;
|
|
45
|
+
if (!targetCompanyId) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const company = user.companies.find((c) => c.id === targetCompanyId);
|
|
50
|
+
if (!company) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return hasRolePermission(company.role, requiredRole);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const isOwner = (companyId?: string): boolean => {
|
|
58
|
+
return hasRole('owner', companyId);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const isAdmin = (companyId?: string): boolean => {
|
|
62
|
+
return hasRole('admin', companyId);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const canEdit = (companyId?: string): boolean => {
|
|
66
|
+
return hasRole('member', companyId);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const canView = (companyId?: string): boolean => {
|
|
70
|
+
return hasRole('viewer', companyId);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
hasRole,
|
|
75
|
+
isOwner,
|
|
76
|
+
isAdmin,
|
|
77
|
+
canEdit,
|
|
78
|
+
canView,
|
|
79
|
+
currentRole,
|
|
80
|
+
currentCompanyId,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-only: email utilities for auth flows
|
|
3
|
+
* Usage: import { createEmailService } from '@startsimpli/auth/email'
|
|
4
|
+
*
|
|
5
|
+
* Call createEmailService(process.env.RESEND_API_KEY) once per app (e.g. in a singleton module).
|
|
6
|
+
* If no API key is provided (dev/test), emails are logged to console instead of sent.
|
|
7
|
+
*
|
|
8
|
+
* resend is an optional peer dependency — the package works without it installed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface EmailOptions {
|
|
12
|
+
to: string;
|
|
13
|
+
subject: string;
|
|
14
|
+
html: string;
|
|
15
|
+
text?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface EmailService {
|
|
19
|
+
sendEmail(options: EmailOptions): Promise<boolean>;
|
|
20
|
+
sendPasswordResetEmail(to: string, resetUrl: string, appName?: string, userName?: string | null): Promise<boolean>;
|
|
21
|
+
sendWelcomeEmail(to: string, name: string | null, appName?: string, appUrl?: string): Promise<boolean>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createEmailService(
|
|
25
|
+
apiKey: string | undefined,
|
|
26
|
+
options: { fromEmail?: string; fromName?: string } = {}
|
|
27
|
+
): EmailService {
|
|
28
|
+
const fromEmail = options.fromEmail ?? 'noreply@startsimpli.com';
|
|
29
|
+
const fromName = options.fromName;
|
|
30
|
+
|
|
31
|
+
// Lazily resolved Resend instance — avoids hard require() at module load time
|
|
32
|
+
// and lets the package work even if resend is not installed.
|
|
33
|
+
type ResendLike = { emails: { send: (payload: unknown) => Promise<{ error: unknown }> } };
|
|
34
|
+
let resendInstance: ResendLike | null = null;
|
|
35
|
+
|
|
36
|
+
async function getResend(): Promise<ResendLike | null> {
|
|
37
|
+
if (!apiKey) return null;
|
|
38
|
+
if (resendInstance) return resendInstance;
|
|
39
|
+
try {
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
41
|
+
const mod = await (Function('m', 'return import(m)')('resend') as Promise<any>);
|
|
42
|
+
resendInstance = new mod.Resend(apiKey) as ResendLike;
|
|
43
|
+
} catch {
|
|
44
|
+
console.warn('[email] resend package not available — falling back to console logging');
|
|
45
|
+
}
|
|
46
|
+
return resendInstance;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const from = fromName ? `${fromName} <${fromEmail}>` : fromEmail;
|
|
50
|
+
|
|
51
|
+
async function sendEmail(opts: EmailOptions): Promise<boolean> {
|
|
52
|
+
const { to, subject, html, text } = opts;
|
|
53
|
+
const resend = await getResend();
|
|
54
|
+
|
|
55
|
+
if (!resend) {
|
|
56
|
+
console.log('=== EMAIL (dev mode - not sent) ===');
|
|
57
|
+
console.log(`To: ${to}`);
|
|
58
|
+
console.log(`Subject: ${subject}`);
|
|
59
|
+
console.log(`Body: ${text ?? html}`);
|
|
60
|
+
console.log('=== END EMAIL ===');
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const { error } = await resend.emails.send({ from, to, subject, html, text });
|
|
66
|
+
if (error) {
|
|
67
|
+
console.error('[email] Failed to send email:', error);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.error('[email] Email service error:', err);
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function sendPasswordResetEmail(
|
|
78
|
+
to: string,
|
|
79
|
+
resetUrl: string,
|
|
80
|
+
appName = 'Simpli',
|
|
81
|
+
userName?: string | null
|
|
82
|
+
): Promise<boolean> {
|
|
83
|
+
const name = userName ?? 'there';
|
|
84
|
+
return sendEmail({
|
|
85
|
+
to,
|
|
86
|
+
subject: `Reset your password - ${appName}`,
|
|
87
|
+
html: `
|
|
88
|
+
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
|
89
|
+
<h2 style="color: #f86c4f;">Reset Your Password</h2>
|
|
90
|
+
<p>Hi ${name},</p>
|
|
91
|
+
<p>We received a request to reset your password for your ${appName} account.</p>
|
|
92
|
+
<p>Click the button below to reset your password. This link will expire in 1 hour.</p>
|
|
93
|
+
<p style="margin: 30px 0;">
|
|
94
|
+
<a href="${resetUrl}"
|
|
95
|
+
style="background-color: #f86c4f; color: white; padding: 12px 24px;
|
|
96
|
+
text-decoration: none; border-radius: 6px; display: inline-block;">
|
|
97
|
+
Reset Password
|
|
98
|
+
</a>
|
|
99
|
+
</p>
|
|
100
|
+
<p>Or copy and paste this link into your browser:</p>
|
|
101
|
+
<p style="color: #666; word-break: break-all;">${resetUrl}</p>
|
|
102
|
+
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
|
|
103
|
+
<p style="color: #999; font-size: 14px;">
|
|
104
|
+
If you didn't request this password reset, you can safely ignore this email.
|
|
105
|
+
Your password will remain unchanged.
|
|
106
|
+
</p>
|
|
107
|
+
</div>
|
|
108
|
+
`,
|
|
109
|
+
text: `Hi ${name},\n\nWe received a request to reset your password for your ${appName} account.\n\nClick this link to reset your password (expires in 1 hour):\n${resetUrl}\n\nIf you didn't request this password reset, you can safely ignore this email.\nYour password will remain unchanged.`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function sendWelcomeEmail(
|
|
114
|
+
to: string,
|
|
115
|
+
name: string | null,
|
|
116
|
+
appName = 'Simpli',
|
|
117
|
+
appUrl = ''
|
|
118
|
+
): Promise<boolean> {
|
|
119
|
+
const displayName = name ?? 'there';
|
|
120
|
+
return sendEmail({
|
|
121
|
+
to,
|
|
122
|
+
subject: `Welcome to ${appName}!`,
|
|
123
|
+
html: `
|
|
124
|
+
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
|
125
|
+
<h2 style="color: #f86c4f;">Welcome, ${displayName}!</h2>
|
|
126
|
+
<p>Your ${appName} account is ready.</p>
|
|
127
|
+
${appUrl ? `<p><a href="${appUrl}" style="background:#f86c4f;color:white;padding:12px 24px;border-radius:6px;text-decoration:none;display:inline-block;">Get started</a></p>` : ''}
|
|
128
|
+
<p>The ${appName} team</p>
|
|
129
|
+
</div>
|
|
130
|
+
`,
|
|
131
|
+
text: `Welcome, ${displayName}!\n\nYour ${appName} account is ready.${appUrl ? `\n\nGet started: ${appUrl}` : ''}\n\nThe ${appName} team`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { sendEmail, sendPasswordResetEmail, sendWelcomeEmail };
|
|
136
|
+
}
|