@plyaz/auth 1.0.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/.github/pull_request_template.md +71 -0
- package/.github/workflows/deploy.yml +9 -0
- package/.github/workflows/publish.yml +14 -0
- package/.github/workflows/security.yml +20 -0
- package/README.md +89 -0
- package/commits.txt +5 -0
- package/dist/common/index.cjs +48 -0
- package/dist/common/index.cjs.map +1 -0
- package/dist/common/index.mjs +43 -0
- package/dist/common/index.mjs.map +1 -0
- package/dist/index.cjs +20411 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.mjs +5139 -0
- package/dist/index.mjs.map +1 -0
- package/eslint.config.mjs +13 -0
- package/index.html +13 -0
- package/package.json +141 -0
- package/src/adapters/auth-adapter-factory.ts +26 -0
- package/src/adapters/auth-adapter.mapper.ts +53 -0
- package/src/adapters/base-auth.adapter.ts +119 -0
- package/src/adapters/clerk/clerk.adapter.ts +204 -0
- package/src/adapters/custom/custom.adapter.ts +119 -0
- package/src/adapters/index.ts +4 -0
- package/src/adapters/next-auth/authOptions.ts +81 -0
- package/src/adapters/next-auth/next-auth.adapter.ts +211 -0
- package/src/api/client.ts +37 -0
- package/src/audit/audit.logger.ts +52 -0
- package/src/client/components/ProtectedRoute.tsx +37 -0
- package/src/client/hooks/useAuth.ts +128 -0
- package/src/client/hooks/useConnectedAccounts.ts +108 -0
- package/src/client/hooks/usePermissions.ts +36 -0
- package/src/client/hooks/useRBAC.ts +36 -0
- package/src/client/hooks/useSession.ts +18 -0
- package/src/client/providers/AuthProvider.tsx +104 -0
- package/src/client/store/auth.store.ts +306 -0
- package/src/client/utils/storage.ts +70 -0
- package/src/common/constants/oauth-providers.ts +49 -0
- package/src/common/errors/auth.errors.ts +64 -0
- package/src/common/errors/specific-auth-errors.ts +201 -0
- package/src/common/index.ts +19 -0
- package/src/common/regex/index.ts +27 -0
- package/src/common/types/auth.types.ts +641 -0
- package/src/common/types/index.ts +297 -0
- package/src/common/utils/index.ts +84 -0
- package/src/core/blacklist/token.blacklist.ts +60 -0
- package/src/core/index.ts +2 -0
- package/src/core/jwt/jwt.manager.ts +131 -0
- package/src/core/session/session.manager.ts +56 -0
- package/src/db/repositories/connected-account.repository.ts +415 -0
- package/src/db/repositories/role.repository.ts +519 -0
- package/src/db/repositories/session.repository.ts +308 -0
- package/src/db/repositories/user.repository.ts +320 -0
- package/src/flows/index.ts +2 -0
- package/src/flows/sign-in.flow.ts +106 -0
- package/src/flows/sign-up.flow.ts +121 -0
- package/src/index.ts +54 -0
- package/src/libs/clerk.helper.ts +36 -0
- package/src/libs/supabase.helper.ts +255 -0
- package/src/libs/supabaseClient.ts +6 -0
- package/src/providers/base/auth-provider.interface.ts +42 -0
- package/src/providers/base/index.ts +1 -0
- package/src/providers/index.ts +2 -0
- package/src/providers/oauth/facebook.provider.ts +97 -0
- package/src/providers/oauth/github.provider.ts +148 -0
- package/src/providers/oauth/google.provider.ts +126 -0
- package/src/providers/oauth/index.ts +3 -0
- package/src/rbac/dynamic-roles.ts +552 -0
- package/src/rbac/index.ts +4 -0
- package/src/rbac/permission-checker.ts +464 -0
- package/src/rbac/role-hierarchy.ts +545 -0
- package/src/rbac/role.manager.ts +75 -0
- package/src/security/csrf/csrf.protection.ts +37 -0
- package/src/security/index.ts +3 -0
- package/src/security/rate-limiting/auth/auth.controller.ts +12 -0
- package/src/security/rate-limiting/auth/rate-limiting.interface.ts +67 -0
- package/src/security/rate-limiting/auth.module.ts +32 -0
- package/src/server/auth.module.ts +158 -0
- package/src/server/decorators/auth.decorator.ts +43 -0
- package/src/server/decorators/auth.decorators.ts +31 -0
- package/src/server/decorators/current-user.decorator.ts +49 -0
- package/src/server/decorators/permission.decorator.ts +49 -0
- package/src/server/guards/auth.guard.ts +56 -0
- package/src/server/guards/custom-throttler.guard.ts +46 -0
- package/src/server/guards/permissions.guard.ts +115 -0
- package/src/server/guards/roles.guard.ts +31 -0
- package/src/server/middleware/auth.middleware.ts +46 -0
- package/src/server/middleware/index.ts +2 -0
- package/src/server/middleware/middleware.ts +11 -0
- package/src/server/middleware/session.middleware.ts +255 -0
- package/src/server/services/account.service.ts +269 -0
- package/src/server/services/auth.service.ts +79 -0
- package/src/server/services/brute-force.service.ts +98 -0
- package/src/server/services/index.ts +15 -0
- package/src/server/services/rate-limiter.service.ts +60 -0
- package/src/server/services/session.service.ts +287 -0
- package/src/server/services/token.service.ts +262 -0
- package/src/session/cookie-store.ts +255 -0
- package/src/session/enhanced-session-manager.ts +406 -0
- package/src/session/index.ts +14 -0
- package/src/session/memory-store.ts +320 -0
- package/src/session/redis-store.ts +443 -0
- package/src/strategies/oauth.strategy.ts +128 -0
- package/src/strategies/traditional-auth.strategy.ts +116 -0
- package/src/tokens/index.ts +4 -0
- package/src/tokens/refresh-token-manager.ts +448 -0
- package/src/tokens/token-validator.ts +311 -0
- package/tsconfig.build.json +28 -0
- package/tsconfig.json +38 -0
- package/tsup.config.mjs +28 -0
- package/vitest.config.mjs +16 -0
- package/vitest.setup.d.ts +2 -0
- package/vitest.setup.d.ts.map +1 -0
- package/vitest.setup.ts +1 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CreateSessionData,
|
|
3
|
+
Session,
|
|
4
|
+
SessionRepository as ISessionRepository,
|
|
5
|
+
} from "@plyaz/types";
|
|
6
|
+
import { createClient } from "@supabase/supabase-js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Repository for managing user sessions
|
|
10
|
+
*
|
|
11
|
+
* @description
|
|
12
|
+
* Handles session lifecycle for both B2C (public) and B2B (backoffice) users.
|
|
13
|
+
* Tracks session expiration, activity, and device information.
|
|
14
|
+
* Schema-aware repository that works with public.sessions or backoffice.sessions.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```typescript
|
|
18
|
+
* const repo = new SessionRepository(url, key, 'public');
|
|
19
|
+
* const session = await repo.create({
|
|
20
|
+
* userId: 'uuid',
|
|
21
|
+
* provider: 'clerk',
|
|
22
|
+
* expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000)
|
|
23
|
+
* });
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export class SessionRepository implements ISessionRepository {
|
|
27
|
+
private supabase;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* STEP 1: Initialize repository with Supabase client
|
|
31
|
+
* @param supabaseUrl - Supabase project URL
|
|
32
|
+
* @param supabaseKey - Supabase service role key
|
|
33
|
+
* @param schema - Database schema ('public' for B2C, 'backoffice' for B2B)
|
|
34
|
+
*/
|
|
35
|
+
constructor(
|
|
36
|
+
supabaseUrl: string,
|
|
37
|
+
supabaseKey: string,
|
|
38
|
+
private schema: "public" | "backoffice" = "public"
|
|
39
|
+
) {
|
|
40
|
+
this.supabase = createClient(supabaseUrl, supabaseKey);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* STEP 2: Create new session
|
|
45
|
+
* Automatically sets last_activity_at to current time
|
|
46
|
+
*
|
|
47
|
+
* @param data - Session creation data
|
|
48
|
+
* @returns Promise resolving to created Session
|
|
49
|
+
* @throws Error if creation fails
|
|
50
|
+
*/
|
|
51
|
+
async create(data: CreateSessionData): Promise<Session> {
|
|
52
|
+
const insertData = this.transformToDbFormat(data);
|
|
53
|
+
|
|
54
|
+
const { data: sessionData, error } = await this.supabase
|
|
55
|
+
.from("sessions")
|
|
56
|
+
.insert(insertData)
|
|
57
|
+
.select()
|
|
58
|
+
.single();
|
|
59
|
+
|
|
60
|
+
if (error || !sessionData)
|
|
61
|
+
throw new Error(`Failed to create session: ${error?.message}`);
|
|
62
|
+
return this.mapToSession(sessionData);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* STEP 3: Find session by ID
|
|
67
|
+
*
|
|
68
|
+
* @param id - Session UUID
|
|
69
|
+
* @returns Promise resolving to Session or null if not found
|
|
70
|
+
*/
|
|
71
|
+
async findById(id: string): Promise<Session | null> {
|
|
72
|
+
const { data, error } = await this.supabase
|
|
73
|
+
.from("sessions")
|
|
74
|
+
.select("*")
|
|
75
|
+
.eq("id", id)
|
|
76
|
+
.single();
|
|
77
|
+
|
|
78
|
+
if (error || !data) return null;
|
|
79
|
+
return this.mapToSession(data);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* STEP 4: Find all sessions for a user
|
|
84
|
+
* Returns sessions ordered by creation date (newest first)
|
|
85
|
+
*
|
|
86
|
+
* @param userId - User UUID
|
|
87
|
+
* @returns Promise resolving to array of Session
|
|
88
|
+
*/
|
|
89
|
+
async findByUserId(userId: string): Promise<Session[]> {
|
|
90
|
+
|
|
91
|
+
this.schema === "backoffice" ? "backoffice_user_id" : "user_id";
|
|
92
|
+
const { data, error } = await this.supabase
|
|
93
|
+
.from("sessions")
|
|
94
|
+
.select("*")
|
|
95
|
+
.eq("user_id", userId)
|
|
96
|
+
.order("created_at", { ascending: false });
|
|
97
|
+
|
|
98
|
+
if (error || !data) return [];
|
|
99
|
+
return data.map(this.mapToSession);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* STEP 5: Validate session
|
|
104
|
+
* Checks if session exists and hasn't expired
|
|
105
|
+
* Updates last_activity_at if valid
|
|
106
|
+
*
|
|
107
|
+
* @param sessionId - Session UUID
|
|
108
|
+
* @returns Promise resolving to Session or null if invalid/expired
|
|
109
|
+
*/
|
|
110
|
+
async validate(sessionId: string): Promise<Session | null> {
|
|
111
|
+
const { data, error } = await this.supabase
|
|
112
|
+
.from("sessions")
|
|
113
|
+
.select("*")
|
|
114
|
+
.eq("id", sessionId)
|
|
115
|
+
.gt("expires_at", new Date().toISOString())
|
|
116
|
+
.single();
|
|
117
|
+
|
|
118
|
+
if (error || !data) return null;
|
|
119
|
+
|
|
120
|
+
await this.updateActivity(sessionId);
|
|
121
|
+
return this.mapToSession(data);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* STEP 6: Invalidate single session (logout)
|
|
126
|
+
*
|
|
127
|
+
* @param sessionId - Session UUID
|
|
128
|
+
* @throws Error if invalidation fails
|
|
129
|
+
*/
|
|
130
|
+
async invalidate(sessionId: string): Promise<void> {
|
|
131
|
+
const { error } = await this.supabase
|
|
132
|
+
.from("sessions")
|
|
133
|
+
.delete()
|
|
134
|
+
.eq("id", sessionId);
|
|
135
|
+
|
|
136
|
+
if (error)
|
|
137
|
+
throw new Error(`Failed to invalidate session: ${error.message}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* STEP 7: Invalidate all sessions for a user (logout all devices)
|
|
142
|
+
*
|
|
143
|
+
* @param userId - User UUID
|
|
144
|
+
* @throws Error if invalidation fails
|
|
145
|
+
*/
|
|
146
|
+
async invalidateAllForUser(userId: string): Promise<void> {
|
|
147
|
+
|
|
148
|
+
this.schema === "backoffice" ? "backoffice_user_id" : "user_id";
|
|
149
|
+
const { error } = await this.supabase
|
|
150
|
+
.from("sessions")
|
|
151
|
+
.delete()
|
|
152
|
+
.eq("user_id", userId);
|
|
153
|
+
|
|
154
|
+
if (error)
|
|
155
|
+
throw new Error(`Failed to invalidate user sessions: ${error.message}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* STEP 8: Update session activity timestamp
|
|
160
|
+
* Called on each authenticated request to track user activity
|
|
161
|
+
*
|
|
162
|
+
* @param sessionId - Session UUID
|
|
163
|
+
*/
|
|
164
|
+
async updateActivity(sessionId: string): Promise<void> {
|
|
165
|
+
await this.supabase
|
|
166
|
+
.from("sessions")
|
|
167
|
+
.update({ last_active_at: new Date() })
|
|
168
|
+
.eq("id", sessionId);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Update last active timestamp
|
|
173
|
+
*/
|
|
174
|
+
async updateLastActive(sessionId: string): Promise<void> {
|
|
175
|
+
await this.updateActivity(sessionId);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Delete session by ID
|
|
180
|
+
*/
|
|
181
|
+
async delete(sessionId: string): Promise<void> {
|
|
182
|
+
await this.invalidate(sessionId);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Delete all sessions for user
|
|
187
|
+
*/
|
|
188
|
+
async deleteByUserId(userId: string): Promise<void> {
|
|
189
|
+
await this.invalidateAllForUser(userId);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Find active sessions by user
|
|
194
|
+
* Query WHERE user_id AND expires_at > NOW()
|
|
195
|
+
* @param userId - User identifier
|
|
196
|
+
* @returns Array of active sessions
|
|
197
|
+
*/
|
|
198
|
+
async findActiveByUser(userId: string): Promise<Session[]> {
|
|
199
|
+
|
|
200
|
+
this.schema === "backoffice" ? "backoffice_user_id" : "user_id";
|
|
201
|
+
const { data, error } = await this.supabase
|
|
202
|
+
.from("sessions")
|
|
203
|
+
.select("*")
|
|
204
|
+
.eq("user_id", userId)
|
|
205
|
+
.gt("expires_at", new Date().toISOString())
|
|
206
|
+
.order("created_at", { ascending: false });
|
|
207
|
+
|
|
208
|
+
if (error || !data) return [];
|
|
209
|
+
return data.map(this.mapToSession);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Invalidate session (set is_valid = false)
|
|
214
|
+
* UPDATE is_valid = false and emit session.invalidated event
|
|
215
|
+
* @param sessionId - Session identifier
|
|
216
|
+
* @returns Invalidation result
|
|
217
|
+
*/
|
|
218
|
+
async invalidateSession(sessionId: string): Promise<void> {
|
|
219
|
+
// For this implementation, we'll delete the session
|
|
220
|
+
// In a real implementation with is_valid column, this would update the flag
|
|
221
|
+
await this.invalidate(sessionId);
|
|
222
|
+
|
|
223
|
+
// Emit session invalidated event
|
|
224
|
+
this.emitSessionInvalidatedEvent(sessionId);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Invalidate all user sessions with count
|
|
229
|
+
* UPDATE all user sessions is_valid = false for "logout all devices" feature
|
|
230
|
+
* @param userId - User identifier
|
|
231
|
+
* @returns Object with invalidated count
|
|
232
|
+
*/
|
|
233
|
+
async invalidateAllUserSessions(
|
|
234
|
+
userId: string
|
|
235
|
+
): Promise<{ invalidatedCount: number }> {
|
|
236
|
+
const userSessions = await this.findByUserId(userId);
|
|
237
|
+
const invalidatedCount = userSessions.length;
|
|
238
|
+
|
|
239
|
+
await this.invalidateAllForUser(userId);
|
|
240
|
+
|
|
241
|
+
return { invalidatedCount };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Clean up expired sessions
|
|
246
|
+
* DELETE WHERE expires_at < NOW() - Background job: Daily at 03:00 UTC
|
|
247
|
+
* @returns Object with deleted count
|
|
248
|
+
*/
|
|
249
|
+
async cleanupExpired(): Promise<{ deletedCount: number }> {
|
|
250
|
+
const { data, error } = await this.supabase
|
|
251
|
+
.from("sessions")
|
|
252
|
+
.delete()
|
|
253
|
+
.lt("expires_at", new Date().toISOString())
|
|
254
|
+
.select("id");
|
|
255
|
+
|
|
256
|
+
if (error) {
|
|
257
|
+
throw new Error(`Failed to cleanup expired sessions: ${error.message}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return { deletedCount: data?.length || 0 };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Emit session invalidated event
|
|
265
|
+
* @param sessionId - Session identifier
|
|
266
|
+
* @private
|
|
267
|
+
*/
|
|
268
|
+
private emitSessionInvalidatedEvent(sessionId: string): void {
|
|
269
|
+
// Mock event emission - in real implementation would use event system
|
|
270
|
+
globalThis.console.log("Event: auth.session.invalidated", {
|
|
271
|
+
sessionId,
|
|
272
|
+
timestamp: new Date(),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Transform camelCase DTO to snake_case database format
|
|
278
|
+
* @private
|
|
279
|
+
*/
|
|
280
|
+
private transformToDbFormat(data: CreateSessionData): Record<string, Record<string, unknown> | string | Date > {
|
|
281
|
+
return {
|
|
282
|
+
user_id: data.userId,
|
|
283
|
+
token_hash: "temp-token-hash", // Required field
|
|
284
|
+
device_info: data.metadata ?? {},
|
|
285
|
+
expires_at: data.expiresAt,
|
|
286
|
+
last_active_at: new Date(),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Map database row to Session interface
|
|
292
|
+
* @private
|
|
293
|
+
*/
|
|
294
|
+
private mapToSession(data: Session): Session {
|
|
295
|
+
return {
|
|
296
|
+
id: data.id,
|
|
297
|
+
userId: data.userId,
|
|
298
|
+
provider: "test", // Default since not in schema
|
|
299
|
+
providerSessionId: undefined,
|
|
300
|
+
expiresAt: new Date(data.expiresAt),
|
|
301
|
+
createdAt: new Date(data.createdAt),
|
|
302
|
+
lastActivityAt: new Date(data.lastActivityAt),
|
|
303
|
+
ipAddress: undefined,
|
|
304
|
+
userAgent: undefined,
|
|
305
|
+
metadata: data.metadata,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { createClient } from '@supabase/supabase-js';
|
|
2
|
+
import type { User, UserRepository as IUserRepository, CreateUserData, UpdateUserData } from '@/common/types/auth.types';
|
|
3
|
+
import type { AUTHPROVIDER, AuthUser } from '@plyaz/types';
|
|
4
|
+
/**
|
|
5
|
+
* Repository for managing user accounts
|
|
6
|
+
*
|
|
7
|
+
* @description
|
|
8
|
+
* Handles CRUD operations for both B2C (public) and B2B (backoffice) users.
|
|
9
|
+
* Schema-aware repository that works with public.users or backoffice.users.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```typescript
|
|
13
|
+
* // B2C users
|
|
14
|
+
* const publicRepo = new UserRepository(url, key, 'public');
|
|
15
|
+
* const user = await publicRepo.findByEmail('user@example.com');
|
|
16
|
+
*
|
|
17
|
+
* // B2B users
|
|
18
|
+
* const backofficeRepo = new UserRepository(url, key, 'backoffice');
|
|
19
|
+
* const admin = await backofficeRepo.findById('uuid');
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export class UserRepository implements IUserRepository {
|
|
23
|
+
private supabase;
|
|
24
|
+
/**
|
|
25
|
+
* STEP 1: Initialize repository with Supabase client
|
|
26
|
+
* @param supabaseUrl - Supabase project URL
|
|
27
|
+
* @param supabaseKey - Supabase service role key
|
|
28
|
+
* @param schema - Database schema ('public' for B2C, 'backoffice' for B2B)
|
|
29
|
+
*/
|
|
30
|
+
constructor(supabaseUrl: string, supabaseKey: string, private schema: 'public' | 'backoffice' = 'public') {
|
|
31
|
+
this.supabase = createClient(supabaseUrl, supabaseKey);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* STEP 2: Find user by ID
|
|
35
|
+
*
|
|
36
|
+
* @param id - User UUID
|
|
37
|
+
* @returns Promise resolving to User or null if not found
|
|
38
|
+
*/
|
|
39
|
+
async findById(id: string): Promise<User | null> {
|
|
40
|
+
const { data, error } = await this.supabase
|
|
41
|
+
.from('users')
|
|
42
|
+
.select('*')
|
|
43
|
+
.eq('id', id)
|
|
44
|
+
.single();
|
|
45
|
+
if (error || !data) return null;
|
|
46
|
+
return this.mapToUser(data);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* STEP 3: Find user by email
|
|
50
|
+
*
|
|
51
|
+
* @param email - User email address
|
|
52
|
+
* @returns Promise resolving to User or null if not found
|
|
53
|
+
*/
|
|
54
|
+
async findByEmail(email: string): Promise<User | null> {
|
|
55
|
+
const { data, error } = await this.supabase
|
|
56
|
+
.from('users')
|
|
57
|
+
.select('*')
|
|
58
|
+
.eq('email', email)
|
|
59
|
+
.single();
|
|
60
|
+
if (error || !data) return null;
|
|
61
|
+
return this.mapToUser(data);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* STEP 4: Find user by provider account
|
|
65
|
+
* Looks up user via connected_accounts table
|
|
66
|
+
*
|
|
67
|
+
* @param provider - Provider name (e.g., 'clerk', 'google')
|
|
68
|
+
* @param providerAccountId - Provider's user ID
|
|
69
|
+
* @returns Promise resolving to User or null if not found
|
|
70
|
+
*/
|
|
71
|
+
async findByProviderAccount(provider: string, providerAccountId: string): Promise<User | null> {
|
|
72
|
+
const { data: accountData, error: accountError } = await this.supabase
|
|
73
|
+
.from('connected_accounts')
|
|
74
|
+
.select('user_id')
|
|
75
|
+
.eq('provider', provider)
|
|
76
|
+
.eq('provider_account_id', providerAccountId)
|
|
77
|
+
.single();
|
|
78
|
+
if (accountError || !accountData) return null;
|
|
79
|
+
return this.findById(accountData.user_id);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* STEP 5: Create new user
|
|
83
|
+
*
|
|
84
|
+
* @param data - User creation data
|
|
85
|
+
* @returns Promise resolving to created User
|
|
86
|
+
* @throws Error if creation fails
|
|
87
|
+
*/
|
|
88
|
+
async create(data: CreateUserData): Promise<User> {
|
|
89
|
+
const insertData = this.transformToDbFormat(data);
|
|
90
|
+
const { data: userData, error } = await this.supabase
|
|
91
|
+
.from('users')
|
|
92
|
+
.insert(insertData)
|
|
93
|
+
.select()
|
|
94
|
+
.single();
|
|
95
|
+
if (error || !userData) throw new Error(`Failed to create user: ${error?.message}`);
|
|
96
|
+
return this.mapToUser(userData);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* STEP 6: Update user
|
|
100
|
+
* Only updates provided fields, sets updated_at automatically
|
|
101
|
+
*
|
|
102
|
+
* @param id - User UUID
|
|
103
|
+
* @param data - Partial update data
|
|
104
|
+
* @returns Promise resolving to updated User
|
|
105
|
+
* @throws Error if update fails
|
|
106
|
+
*/
|
|
107
|
+
async update(id: string, data: UpdateUserData): Promise<User> {
|
|
108
|
+
const updateData = this.buildUpdateData(data);
|
|
109
|
+
const { data: userData, error } = await this.supabase
|
|
110
|
+
.from('users')
|
|
111
|
+
.update(updateData)
|
|
112
|
+
.eq('id', id)
|
|
113
|
+
.select()
|
|
114
|
+
.single();
|
|
115
|
+
if (error || !userData) throw new Error(`Failed to update user: ${error?.message}`);
|
|
116
|
+
return this.mapToUser(userData);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* STEP 7: Delete user
|
|
120
|
+
* Cascades to related records (sessions, roles, etc.)
|
|
121
|
+
*
|
|
122
|
+
* @param id - User UUID
|
|
123
|
+
* @throws Error if deletion fails
|
|
124
|
+
*/
|
|
125
|
+
async delete(id: string): Promise<void> {
|
|
126
|
+
const { error } = await this.supabase
|
|
127
|
+
.from('users')
|
|
128
|
+
.delete()
|
|
129
|
+
.eq('id', id);
|
|
130
|
+
if (error) throw new Error(`Failed to delete user: ${error.message}`);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Find user by email and password for backoffice authentication
|
|
134
|
+
*/
|
|
135
|
+
async findByCredentials(email: string, passwordHash: string): Promise<User | null> {
|
|
136
|
+
const { data, error } = await this.supabase
|
|
137
|
+
.from('users')
|
|
138
|
+
.select('*')
|
|
139
|
+
.eq('email', email)
|
|
140
|
+
.eq('password_hash', passwordHash)
|
|
141
|
+
.single();
|
|
142
|
+
if (error || !data) return null;
|
|
143
|
+
return this.mapToUser(data);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Assign role to user
|
|
147
|
+
*/
|
|
148
|
+
async assignRole(userId: string, role: string, assignedBy?: string): Promise<void> {
|
|
149
|
+
const { error } = await this.supabase
|
|
150
|
+
.from('user_roles')
|
|
151
|
+
.insert({
|
|
152
|
+
user_id: userId,
|
|
153
|
+
role: role,
|
|
154
|
+
assigned_by: assignedBy,
|
|
155
|
+
status: 'ACTIVE'
|
|
156
|
+
});
|
|
157
|
+
if (error) throw new Error(`Failed to assign role: ${error.message}`);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Remove role from user
|
|
161
|
+
*/
|
|
162
|
+
async removeRole(userId: string, role: string): Promise<void> {
|
|
163
|
+
const { error } = await this.supabase
|
|
164
|
+
.from('user_roles')
|
|
165
|
+
.delete()
|
|
166
|
+
.eq('user_id', userId)
|
|
167
|
+
.eq('role', role);
|
|
168
|
+
if (error) throw new Error(`Failed to remove role: ${error.message}`);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Get user roles
|
|
172
|
+
*/
|
|
173
|
+
async getUserRoles(userId: string): Promise<string[]> {
|
|
174
|
+
const { data, error } = await this.supabase
|
|
175
|
+
.from('user_roles')
|
|
176
|
+
.select('role')
|
|
177
|
+
.eq('user_id', userId)
|
|
178
|
+
.eq('status', 'ACTIVE');
|
|
179
|
+
if (error || !data) return [];
|
|
180
|
+
return data.map((row: { role: string; }) => row.role);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Check permission for roles
|
|
184
|
+
*/
|
|
185
|
+
async checkPermission(roles: string[], permission: string): Promise<boolean> {
|
|
186
|
+
// Implementation would check roles table for permissions
|
|
187
|
+
// For now, return basic role hierarchy
|
|
188
|
+
return roles.includes('ADMIN') || roles.includes(permission);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Update last login timestamp
|
|
192
|
+
*/
|
|
193
|
+
async updateLastLogin(userId: string): Promise<void> {
|
|
194
|
+
const { error } = await this.supabase
|
|
195
|
+
.from('users')
|
|
196
|
+
.update({ last_login_at: new Date() })
|
|
197
|
+
.eq('id', userId);
|
|
198
|
+
if (error) throw new Error(`Failed to update last login: ${error.message}`);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Find user by Clerk ID
|
|
202
|
+
* Query users WHERE clerk_user_id = clerkId
|
|
203
|
+
* @param clerkId - Clerk user identifier
|
|
204
|
+
* @returns User or null if not found
|
|
205
|
+
*/
|
|
206
|
+
async findByClerkId(clerkId: string): Promise<User | null> {
|
|
207
|
+
const { data, error } = await this.supabase
|
|
208
|
+
.from('users')
|
|
209
|
+
.select('*')
|
|
210
|
+
.eq('clerk_user_id', clerkId)
|
|
211
|
+
.single();
|
|
212
|
+
if (error || !data) return null;
|
|
213
|
+
return this.mapToUser(data);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Find user by wallet address
|
|
217
|
+
* Query via connected_accounts WHERE provider = 'web3'
|
|
218
|
+
* @param address - Wallet address
|
|
219
|
+
* @returns User or null if not found
|
|
220
|
+
*/
|
|
221
|
+
async findByWalletAddress(address: string): Promise<User | null> {
|
|
222
|
+
const { data: accountData, error: accountError } = await this.supabase
|
|
223
|
+
.from('connected_accounts')
|
|
224
|
+
.select('user_id')
|
|
225
|
+
.eq('provider_type', 'WEB3')
|
|
226
|
+
.eq('wallet_address', address)
|
|
227
|
+
.eq('is_active', true)
|
|
228
|
+
.single();
|
|
229
|
+
if (accountError || !accountData) return null;
|
|
230
|
+
return this.findById(accountData.user_id);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Update onboarding status
|
|
234
|
+
* UPDATE onboarding_completed_at (if complete)
|
|
235
|
+
* @param userId - User identifier
|
|
236
|
+
* @param status - Onboarding status ('completed' | 'pending' | 'skipped')
|
|
237
|
+
* @returns Updated user
|
|
238
|
+
*/
|
|
239
|
+
async updateOnboardingStatus(userId: string, status: string): Promise<User> {
|
|
240
|
+
const updateData: Partial<AuthUser> = {
|
|
241
|
+
updatedAt: new Date()
|
|
242
|
+
};
|
|
243
|
+
if (status === 'completed') {
|
|
244
|
+
updateData.updatedAt = new Date();
|
|
245
|
+
}
|
|
246
|
+
const { data: userData, error } = await this.supabase
|
|
247
|
+
.from('users')
|
|
248
|
+
.update(updateData)
|
|
249
|
+
.eq('id', userId)
|
|
250
|
+
.select()
|
|
251
|
+
.single();
|
|
252
|
+
if (error || !userData) throw new Error(`Failed to update onboarding status: ${error?.message}`);
|
|
253
|
+
return this.mapToUser(userData);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Transform camelCase DTO to snake_case database format
|
|
257
|
+
* @private
|
|
258
|
+
*/
|
|
259
|
+
private transformToDbFormat(data: CreateUserData): Record<string, string | AUTHPROVIDER | undefined> {
|
|
260
|
+
return {
|
|
261
|
+
email: data.email,
|
|
262
|
+
clerk_user_id: data.clerkUserId,
|
|
263
|
+
auth_provider: data.authProvider ?? 'EMAIL',
|
|
264
|
+
first_name: data.firstName,
|
|
265
|
+
last_name: data.lastName,
|
|
266
|
+
display_name: data.displayName,
|
|
267
|
+
phone_number: data.phoneNumber,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Build update data with only defined fields
|
|
272
|
+
* @private
|
|
273
|
+
*/
|
|
274
|
+
private buildUpdateData(data: UpdateUserData): Record<string, string> {
|
|
275
|
+
const result: Record<string, string> = { updated_at: new Date().toString() };
|
|
276
|
+
Object.entries(data).forEach(([key, value]) => {
|
|
277
|
+
if (value !== undefined) {
|
|
278
|
+
const dbKey = this.camelToSnake(key);
|
|
279
|
+
result[dbKey] = value;
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
return result;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Convert camelCase to snake_case
|
|
286
|
+
* @private
|
|
287
|
+
*/
|
|
288
|
+
private camelToSnake(str: string): string {
|
|
289
|
+
return str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Map database row to User interface
|
|
293
|
+
* @private
|
|
294
|
+
*/
|
|
295
|
+
private mapToUser(data:User): User {
|
|
296
|
+
return {
|
|
297
|
+
id: data.id,
|
|
298
|
+
email: data.email,
|
|
299
|
+
clerkUserId: data.clerkUserId,
|
|
300
|
+
authProvider: data.authProvider,
|
|
301
|
+
firstName: data.firstName,
|
|
302
|
+
lastName: data.lastName,
|
|
303
|
+
displayName: data.displayName,
|
|
304
|
+
avatarUrl: undefined,
|
|
305
|
+
phoneNumber: data.phoneNumber,
|
|
306
|
+
isActive: data.isActive ?? true,
|
|
307
|
+
isVerified: data.isVerified ?? false,
|
|
308
|
+
createdAt: new Date(data.createdAt),
|
|
309
|
+
updatedAt: new Date(data.updatedAt),
|
|
310
|
+
lastLoginAt: data.lastLoginAt ? new Date(data.lastLoginAt) : undefined,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
|