@lastbrain/module-auth 0.1.6 → 0.1.9

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.
@@ -38,6 +38,11 @@ const authBuildConfig: ModuleBuildConfig = {
38
38
  path: "/users",
39
39
  componentExport: "AdminUsersPage",
40
40
  },
41
+ {
42
+ section: "admin",
43
+ path: "/users/[id]",
44
+ componentExport: "UserPage",
45
+ },
41
46
  ],
42
47
  apis: [
43
48
  {
@@ -82,6 +87,20 @@ const authBuildConfig: ModuleBuildConfig = {
82
87
  entryPoint: "api/admin/users",
83
88
  authRequired: true,
84
89
  },
90
+ {
91
+ method: "GET",
92
+ path: "/api/admin/users/[id]",
93
+ handlerExport: "GET",
94
+ entryPoint: "api/admin/users/[id]",
95
+ authRequired: true,
96
+ },
97
+ {
98
+ method: "POST",
99
+ path: "/api/admin/users/[id]/notifications",
100
+ handlerExport: "POST",
101
+ entryPoint: "api/admin/users/[id]/notifications",
102
+ authRequired: true,
103
+ },
85
104
  ],
86
105
  migrations: {
87
106
  enabled: true,
@@ -91,6 +110,7 @@ const authBuildConfig: ModuleBuildConfig = {
91
110
  "20251112000000_user_init.sql",
92
111
  "20251112000001_auto_profile_and_admin_view.sql",
93
112
  "20251112000002_sync_avatars.sql",
113
+ "20251124000001_add_get_admin_user_details.sql",
94
114
  ],
95
115
  migrationsDownPath: "supabase/migrations-down",
96
116
  },
@@ -151,6 +171,32 @@ const authBuildConfig: ModuleBuildConfig = {
151
171
  },
152
172
  ],
153
173
  },
174
+ realtime: {
175
+ moduleId: "module-auth",
176
+ tables: [
177
+ {
178
+ schema: "public",
179
+ table: "user_notifications",
180
+ event: "*",
181
+ filter: "owner_id=eq.${USER_ID}",
182
+ broadcast: "user_notifications_updated",
183
+ },
184
+ {
185
+ schema: "public",
186
+ table: "user_profil",
187
+ event: "*",
188
+ filter: "owner_id=eq.${USER_ID}",
189
+ broadcast: "user_profil_updated",
190
+ },
191
+ {
192
+ schema: "public",
193
+ table: "user_addresses",
194
+ event: "*",
195
+ filter: "owner_id=eq.${USER_ID}",
196
+ broadcast: "user_address_updated",
197
+ },
198
+ ],
199
+ },
154
200
  };
155
201
 
156
202
  export default authBuildConfig;
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ export { DashboardPage } from "./web/auth/dashboard.js";
6
6
  export { ProfilePage } from "./web/auth/profile.js";
7
7
  export { ReglagePage } from "./web/auth/reglage.js";
8
8
  export { AdminUsersPage } from "./web/admin/users.js";
9
+ export { default as UserPage } from "./web/admin/users/[id].js";
9
10
  // Documentation
10
11
  export { AuthModuleDoc } from "./components/Doc.js";
11
12
  // Configuration de build (utilisée par les scripts)
@@ -0,0 +1,348 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback } from "react";
4
+ import {
5
+ Card,
6
+ CardHeader,
7
+ CardBody,
8
+ Tabs,
9
+ Tab,
10
+ Avatar,
11
+ Chip,
12
+ Button,
13
+ Input,
14
+ Textarea,
15
+ Select,
16
+ SelectItem,
17
+ Spacer,
18
+ Spinner,
19
+ addToast,
20
+ } from "@lastbrain/ui";
21
+ import { User, Bell, Settings, Save } from "lucide-react";
22
+ import { useAuth } from "@lastbrain/core";
23
+
24
+ interface UserProfile {
25
+ id: string;
26
+ email: string;
27
+ full_name: string;
28
+ avatar_url: string;
29
+ created_at: string;
30
+ last_sign_in_at: string;
31
+ raw_app_meta_data: Record<string, unknown>;
32
+ raw_user_meta_data: Record<string, unknown>;
33
+ }
34
+
35
+ interface UserDetailPageProps {
36
+ userId: string;
37
+ }
38
+
39
+ export function UserDetailPage({ userId }: UserDetailPageProps) {
40
+ const { user: _currentUser } = useAuth();
41
+ const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
42
+ const [loading, setLoading] = useState(true);
43
+ const [notificationTitle, setNotificationTitle] = useState("");
44
+ const [notificationMessage, setNotificationMessage] = useState("");
45
+ const [notificationType, setNotificationType] = useState("info");
46
+ const [sendingNotification, setSendingNotification] = useState(false);
47
+
48
+ // Fonction pour charger les données de l'utilisateur
49
+ const fetchUserProfile = useCallback(async () => {
50
+ try {
51
+ setLoading(true);
52
+
53
+ // Utiliser l'API route pour récupérer les détails de l'utilisateur
54
+ const response = await fetch(`/api/admin/users/${userId}`);
55
+
56
+ if (!response.ok) {
57
+ if (response.status === 404) {
58
+ console.error("Utilisateur non trouvé");
59
+ setUserProfile(null);
60
+ return;
61
+ }
62
+ const errorData = await response.json();
63
+ throw new Error(errorData.error || "Failed to fetch user details");
64
+ }
65
+
66
+ const userDetails = await response.json();
67
+ setUserProfile(userDetails);
68
+ } catch (error) {
69
+ console.error("Erreur lors du chargement du profil:", error);
70
+ setUserProfile(null);
71
+ } finally {
72
+ setLoading(false);
73
+ }
74
+ }, [userId]);
75
+
76
+ // Charger les données de l'utilisateur
77
+ useEffect(() => {
78
+ fetchUserProfile();
79
+ }, [fetchUserProfile]);
80
+
81
+ const handleSendNotification = async () => {
82
+ if (!notificationTitle.trim() || !notificationMessage.trim()) {
83
+ addToast({
84
+ color: "danger",
85
+ title: "Veuillez remplir le titre et le message",
86
+ });
87
+ return;
88
+ }
89
+
90
+ try {
91
+ setSendingNotification(true);
92
+
93
+ // Utiliser l'API route pour envoyer la notification
94
+ const response = await fetch(`/api/admin/users/${userId}/notifications`, {
95
+ method: "POST",
96
+ headers: {
97
+ "Content-Type": "application/json",
98
+ },
99
+ body: JSON.stringify({
100
+ title: notificationTitle.trim(),
101
+ message: notificationMessage.trim(),
102
+ type: notificationType,
103
+ }),
104
+ });
105
+
106
+ if (!response.ok) {
107
+ const errorData = await response.json();
108
+ throw new Error(errorData.error || "Failed to send notification");
109
+ }
110
+
111
+ // Reset du formulaire
112
+ setNotificationTitle("");
113
+ setNotificationMessage("");
114
+ setNotificationType("info");
115
+
116
+ addToast({
117
+ color: "success",
118
+ title: "Notification envoyée avec succès",
119
+ });
120
+ } catch {
121
+ addToast({
122
+ color: "danger",
123
+ title: "Erreur lors de l'envoi de la notification",
124
+ });
125
+ } finally {
126
+ setSendingNotification(false);
127
+ }
128
+ };
129
+
130
+ if (loading) {
131
+ return (
132
+ <div className="flex justify-center items-center min-h-64">
133
+ <Spinner size="lg" />
134
+ </div>
135
+ );
136
+ }
137
+
138
+ if (!userProfile) {
139
+ return (
140
+ <Card>
141
+ <CardBody>
142
+ <p className="text-center text-gray-500">Utilisateur non trouvé</p>
143
+ </CardBody>
144
+ </Card>
145
+ );
146
+ }
147
+
148
+ const isAdmin =
149
+ Array.isArray(userProfile.raw_app_meta_data?.roles) &&
150
+ userProfile.raw_app_meta_data.roles.includes("admin");
151
+
152
+ return (
153
+ <div className="mt-4 space-y-6">
154
+ {/* Header utilisateur */}
155
+ <Card>
156
+ <CardHeader className="flex gap-4">
157
+ <Avatar
158
+ src={userProfile.avatar_url}
159
+ name={userProfile.full_name || userProfile.email}
160
+ size="lg"
161
+ />
162
+ <div className="flex flex-col gap-1">
163
+ <h1 className="text-2xl font-bold">
164
+ {userProfile.full_name || userProfile.email}
165
+ </h1>
166
+ <p className="text-gray-500">{userProfile.email}</p>
167
+ <div className="flex gap-2">
168
+ <Chip color={isAdmin ? "danger" : "primary"} size="sm">
169
+ {isAdmin ? "Administrateur" : "Utilisateur"}
170
+ </Chip>
171
+ <Chip color="default" size="sm">
172
+ ID: {userId}
173
+ </Chip>
174
+ </div>
175
+ </div>
176
+ </CardHeader>
177
+ </Card>
178
+
179
+ {/* Tabs */}
180
+ <Card>
181
+ <CardBody>
182
+ <Tabs
183
+ aria-label="Options utilisateur"
184
+ color="primary"
185
+ variant="underlined"
186
+ >
187
+ {/* Tab Profil */}
188
+ <Tab
189
+ key="profile"
190
+ title={
191
+ <div className="flex items-center space-x-2">
192
+ <User size={16} />
193
+ <span>Profil</span>
194
+ </div>
195
+ }
196
+ >
197
+ <div className="space-y-4 mt-4">
198
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
199
+ <div>
200
+ <h3 className="font-semibold mb-2">
201
+ Informations personnelles
202
+ </h3>
203
+ <div className="space-y-2 text-sm">
204
+ <p>
205
+ <span className="font-medium">Nom complet:</span>{" "}
206
+ {userProfile.full_name || "Non renseigné"}
207
+ </p>
208
+ <p>
209
+ <span className="font-medium">Email:</span>{" "}
210
+ {userProfile.email}
211
+ </p>
212
+ <p>
213
+ <span className="font-medium">Date de création:</span>{" "}
214
+ {userProfile.created_at
215
+ ? new Date(
216
+ userProfile.created_at,
217
+ ).toLocaleDateString()
218
+ : "N/A"}
219
+ </p>
220
+ <p>
221
+ <span className="font-medium">Dernière connexion:</span>{" "}
222
+ {userProfile.last_sign_in_at
223
+ ? new Date(
224
+ userProfile.last_sign_in_at,
225
+ ).toLocaleDateString()
226
+ : "N/A"}
227
+ </p>
228
+ </div>
229
+ </div>
230
+ <div>
231
+ <h3 className="font-semibold mb-2">Rôles et permissions</h3>
232
+ <div className="space-y-2">
233
+ <Chip color={isAdmin ? "danger" : "default"}>
234
+ {isAdmin ? "Administrateur" : "Utilisateur standard"}
235
+ </Chip>
236
+ </div>
237
+ </div>
238
+ </div>
239
+ </div>
240
+ </Tab>
241
+
242
+ {/* Tab Notifications */}
243
+ <Tab
244
+ key="notifications"
245
+ title={
246
+ <div className="flex items-center space-x-2">
247
+ <Bell size={16} />
248
+ <span>Notifications</span>
249
+ </div>
250
+ }
251
+ >
252
+ <div className="space-y-4 mt-4">
253
+ <h3 className="font-semibold">Envoyer une notification</h3>
254
+
255
+ <div className="space-y-4">
256
+ <Input
257
+ label="Titre de la notification"
258
+ placeholder="Ex: Nouveau message important"
259
+ value={notificationTitle}
260
+ onChange={(e) => setNotificationTitle(e.target.value)}
261
+ maxLength={100}
262
+ />
263
+
264
+ <Textarea
265
+ label="Message"
266
+ placeholder="Contenu de la notification..."
267
+ value={notificationMessage}
268
+ onChange={(e) => setNotificationMessage(e.target.value)}
269
+ maxLength={500}
270
+ minRows={3}
271
+ />
272
+
273
+ <Select
274
+ label="Type de notification"
275
+ selectedKeys={[notificationType]}
276
+ onSelectionChange={(keys) =>
277
+ setNotificationType(Array.from(keys)[0] as string)
278
+ }
279
+ >
280
+ <SelectItem key="info">Information</SelectItem>
281
+ <SelectItem key="warning">Avertissement</SelectItem>
282
+ <SelectItem key="danger">Danger</SelectItem>
283
+ <SelectItem key="success">Succès</SelectItem>
284
+ </Select>
285
+
286
+ <Button
287
+ color="primary"
288
+ onPress={handleSendNotification}
289
+ isLoading={sendingNotification}
290
+ startContent={<Bell size={16} />}
291
+ isDisabled={
292
+ !notificationTitle.trim() || !notificationMessage.trim()
293
+ }
294
+ >
295
+ Envoyer la notification
296
+ </Button>
297
+ </div>
298
+ </div>
299
+ </Tab>
300
+
301
+ {/* Tab Paramètres */}
302
+ <Tab
303
+ key="settings"
304
+ title={
305
+ <div className="flex items-center space-x-2">
306
+ <Settings size={16} />
307
+ <span>Paramètres</span>
308
+ </div>
309
+ }
310
+ >
311
+ <div className="space-y-4 mt-4">
312
+ <h3 className="font-semibold">Actions administrateur</h3>
313
+
314
+ <div className="space-y-3 space-x-5">
315
+ <Button color="warning" variant="bordered" size="sm">
316
+ Réinitialiser le mot de passe
317
+ </Button>
318
+
319
+ <Button color="danger" variant="bordered" size="sm">
320
+ Suspendre le compte
321
+ </Button>
322
+
323
+ <Button color="secondary" variant="bordered" size="sm">
324
+ Promouvoir en administrateur
325
+ </Button>
326
+ </div>
327
+
328
+ <div className="mt-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
329
+ <h4 className="font-medium mb-2">Métadonnées techniques</h4>
330
+ <pre className="text-xs text-gray-600 dark:text-gray-400 overflow-auto">
331
+ {JSON.stringify(
332
+ {
333
+ app_metadata: userProfile.raw_app_meta_data,
334
+ user_metadata: userProfile.raw_user_meta_data,
335
+ },
336
+ null,
337
+ 2,
338
+ )}
339
+ </pre>
340
+ </div>
341
+ </div>
342
+ </Tab>
343
+ </Tabs>
344
+ </CardBody>
345
+ </Card>
346
+ </div>
347
+ );
348
+ }
@@ -0,0 +1,12 @@
1
+ import { UserDetailPage } from "../user-detail.js";
2
+
3
+ interface UserPageProps {
4
+ params: Promise<{
5
+ id: string;
6
+ }>;
7
+ }
8
+
9
+ export default async function UserPage({ params }: UserPageProps) {
10
+ const { id } = await params;
11
+ return <UserDetailPage userId={id} />;
12
+ }
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useState } from "react";
3
+ import { useCallback, useEffect, useState, useId } from "react";
4
4
  import {
5
5
  Card,
6
6
  CardBody,
@@ -17,32 +17,17 @@ import {
17
17
  Button,
18
18
  Pagination,
19
19
  Avatar,
20
- addToast,
21
20
  } from "@lastbrain/ui";
22
- import { Users, Search, RefreshCw } from "lucide-react";
21
+ import { Users, Search, RefreshCw, Eye } from "lucide-react";
22
+ import { useRouter } from "next/navigation";
23
23
 
24
24
  interface User {
25
25
  id: string;
26
26
  email: string;
27
27
  created_at: string;
28
- email_confirmed_at?: string;
29
28
  last_sign_in_at?: string;
30
- role?: string;
31
29
  full_name?: string;
32
- avatar_path?: string;
33
- metadata?: Record<string, unknown>;
34
- profile: {
35
- first_name?: string;
36
- last_name?: string;
37
- avatar_url?: string;
38
- company?: string;
39
- location?: string;
40
- bio?: string;
41
- phone?: string;
42
- website?: string;
43
- language?: string;
44
- timezone?: string;
45
- };
30
+ avatar_url?: string;
46
31
  }
47
32
 
48
33
  interface PaginationData {
@@ -53,6 +38,8 @@ interface PaginationData {
53
38
  }
54
39
 
55
40
  export function AdminUsersPage() {
41
+ const router = useRouter();
42
+ const searchInputId = useId();
56
43
  const [users, setUsers] = useState<User[]>([]);
57
44
  const [isLoading, setIsLoading] = useState(true);
58
45
  const [error, setError] = useState<string | null>(null);
@@ -67,44 +54,41 @@ export function AdminUsersPage() {
67
54
  const fetchUsers = useCallback(async () => {
68
55
  try {
69
56
  setIsLoading(true);
57
+
58
+ // Utiliser l'API route au lieu d'appeler directement Supabase
70
59
  const params = new URLSearchParams({
71
60
  page: pagination.page.toString(),
72
61
  per_page: pagination.per_page.toString(),
73
62
  });
74
63
 
75
- if (searchQuery) {
76
- params.append("search", searchQuery);
64
+ if (searchQuery.trim()) {
65
+ params.append("search", searchQuery.trim());
77
66
  }
78
67
 
79
68
  const response = await fetch(`/api/admin/users?${params}`);
80
69
 
81
- if (response.status === 403) {
82
- setError("You don't have permission to access this page");
83
- addToast({
84
- title: "Access Denied",
85
- description: "Superadmin access required",
86
- color: "danger",
87
- });
88
- return;
89
- }
90
-
91
70
  if (!response.ok) {
92
- throw new Error("Failed to fetch users");
71
+ const errorData = await response.json();
72
+ throw new Error(errorData.error || "Failed to fetch users");
93
73
  }
94
74
 
95
75
  const result = await response.json();
96
- setUsers(result.data || []);
97
- if (result.pagination) {
98
- setPagination(result.pagination);
99
- }
76
+
77
+ // L'API retourne soit { data, pagination } soit directement { users, pagination }
78
+ const usersData = result.data || result.users || [];
79
+ const paginationData = result.pagination || {
80
+ page: pagination.page,
81
+ per_page: pagination.per_page,
82
+ total: 0,
83
+ total_pages: 0,
84
+ };
85
+
86
+ setUsers(usersData);
87
+ setPagination(paginationData);
100
88
  setError(null);
101
89
  } catch (err) {
102
90
  setError(err instanceof Error ? err.message : "An error occurred");
103
- addToast({
104
- title: "Error",
105
- description: "Failed to load users",
106
- color: "danger",
107
- });
91
+ console.error("Erreur lors du chargement des utilisateurs:", err);
108
92
  } finally {
109
93
  setIsLoading(false);
110
94
  }
@@ -114,17 +98,23 @@ export function AdminUsersPage() {
114
98
  fetchUsers();
115
99
  }, [fetchUsers]);
116
100
 
117
- const handleSearch = () => {
101
+ const handleSearch = useCallback(() => {
118
102
  setPagination((prev) => ({ ...prev, page: 1 }));
119
- fetchUsers();
120
- };
103
+ }, []);
121
104
 
122
- const handlePageChange = (page: number) => {
105
+ const handlePageChange = useCallback((page: number) => {
123
106
  setPagination((prev) => ({ ...prev, page }));
124
- };
107
+ }, []);
108
+
109
+ const handleViewUser = useCallback(
110
+ (userId: string) => {
111
+ router.push(`/admin/auth/users/${userId}`);
112
+ },
113
+ [router],
114
+ );
125
115
 
126
116
  const formatDate = (dateString: string) => {
127
- return new Date(dateString).toLocaleDateString("en-US", {
117
+ return new Date(dateString).toLocaleDateString("fr-FR", {
128
118
  year: "numeric",
129
119
  month: "short",
130
120
  day: "numeric",
@@ -155,6 +145,7 @@ export function AdminUsersPage() {
155
145
  <div className="flex flex-col md:flex-row gap-4 w-full">
156
146
  <div className="flex gap-2 flex-1">
157
147
  <Input
148
+ id={searchInputId}
158
149
  placeholder="Search by email or name..."
159
150
  value={searchQuery}
160
151
  onChange={(e) => setSearchQuery(e.target.value)}
@@ -199,58 +190,40 @@ export function AdminUsersPage() {
199
190
  <TableHeader>
200
191
  <TableColumn>USER</TableColumn>
201
192
  <TableColumn>EMAIL</TableColumn>
202
- <TableColumn>ROLE</TableColumn>
203
- <TableColumn>COMPANY</TableColumn>
204
193
  <TableColumn>LAST SIGN IN</TableColumn>
205
194
  <TableColumn>CREATED</TableColumn>
206
- <TableColumn>STATUS</TableColumn>
195
+ <TableColumn>ACTIONS</TableColumn>
207
196
  </TableHeader>
208
197
  <TableBody>
209
198
  {users.map((user) => {
210
- const fullName =
211
- user.profile?.first_name && user.profile?.last_name
212
- ? `${user.profile.first_name} ${user.profile.last_name}`
213
- : user.full_name || "N/A";
214
-
215
- const roleColor =
216
- user.role === "admin" || user.role === "superadmin"
217
- ? "danger"
218
- : user.role === "moderator"
219
- ? "secondary"
220
- : "default";
199
+ const displayName = user.full_name || user.email;
221
200
 
222
201
  return (
223
202
  <TableRow key={user.id}>
224
203
  <TableCell>
225
204
  <div className="flex items-center gap-2">
226
205
  <Avatar
227
- src={`/api/storage/${user.profile?.avatar_url}`}
228
- name={fullName}
206
+ src={
207
+ user.avatar_url
208
+ ? `/api/storage/${user.avatar_url}`
209
+ : undefined
210
+ }
211
+ name={displayName}
229
212
  size="sm"
230
213
  />
231
214
  <span className="text-small font-medium">
232
- {fullName}
215
+ {displayName}
233
216
  </span>
234
217
  </div>
235
218
  </TableCell>
236
219
  <TableCell>
237
220
  <span className="text-small">{user.email}</span>
238
221
  </TableCell>
239
- <TableCell>
240
- <Chip color={roleColor} size="sm" variant="flat">
241
- {user.role || "user"}
242
- </Chip>
243
- </TableCell>
244
- <TableCell>
245
- <span className="text-small">
246
- {user.profile?.company || "-"}
247
- </span>
248
- </TableCell>
249
222
  <TableCell>
250
223
  <span className="text-small">
251
224
  {user.last_sign_in_at
252
225
  ? formatDate(user.last_sign_in_at)
253
- : "Never"}
226
+ : "Jamais"}
254
227
  </span>
255
228
  </TableCell>
256
229
  <TableCell>
@@ -259,9 +232,15 @@ export function AdminUsersPage() {
259
232
  </span>
260
233
  </TableCell>
261
234
  <TableCell>
262
- <Chip color="success" size="sm" variant="flat">
263
- Active
264
- </Chip>
235
+ <Button
236
+ size="sm"
237
+ variant="flat"
238
+ color="primary"
239
+ onPress={() => handleViewUser(user.id)}
240
+ startContent={<Eye size={14} />}
241
+ >
242
+ Voir
243
+ </Button>
265
244
  </TableCell>
266
245
  </TableRow>
267
246
  );