create-handover 0.1.1
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 +61 -0
- package/index.mjs +243 -0
- package/package.json +31 -0
- package/template/README.md +61 -0
- package/template/app/admin/login/page.tsx +261 -0
- package/template/app/admin/page.tsx +1346 -0
- package/template/app/favicon.ico +0 -0
- package/template/app/globals.css +118 -0
- package/template/app/layout.tsx +36 -0
- package/template/app/page.tsx +78 -0
- package/template/components/HandoverProvider.tsx +128 -0
- package/template/components/Modal.tsx +75 -0
- package/template/eslint.config.mjs +18 -0
- package/template/lib/branding.ts +12 -0
- package/template/lib/handover.integration.test.ts +322 -0
- package/template/lib/handover.ts +389 -0
- package/template/next.config.ts +16 -0
- package/template/package.json +29 -0
- package/template/pnpm-workspace.yaml +3 -0
- package/template/postcss.config.mjs +7 -0
- package/template/public/file.svg +1 -0
- package/template/public/globe.svg +1 -0
- package/template/public/next.svg +1 -0
- package/template/public/vercel.svg +1 -0
- package/template/public/window.svg +1 -0
- package/template/tsconfig.json +34 -0
|
@@ -0,0 +1,1346 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { useHandover } from "@/components/HandoverProvider";
|
|
6
|
+
import { Modal } from "@/components/Modal";
|
|
7
|
+
import { Upload, LogOut, Image as ImageIcon, Type, Copy, Plus, Trash2, Pencil, Users, Shield, UserX, FileText } from "lucide-react";
|
|
8
|
+
import { clsx } from "clsx";
|
|
9
|
+
import { branding, getBrandInitial } from "@/lib/branding";
|
|
10
|
+
|
|
11
|
+
type AdminRole = "owner" | "admin" | "editor" | "viewer";
|
|
12
|
+
type AdminStatus = "active" | "disabled" | "locked";
|
|
13
|
+
|
|
14
|
+
type AdminUser = {
|
|
15
|
+
id: string;
|
|
16
|
+
username: string;
|
|
17
|
+
role: AdminRole;
|
|
18
|
+
status: AdminStatus;
|
|
19
|
+
lastLoginAt?: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type SessionUser = {
|
|
23
|
+
id: string;
|
|
24
|
+
username: string;
|
|
25
|
+
role: AdminRole;
|
|
26
|
+
status: AdminStatus;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function getSessionToken(): string | null {
|
|
30
|
+
return localStorage.getItem("handover_admin_session");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function clearSession() {
|
|
34
|
+
localStorage.removeItem("handover_admin_session");
|
|
35
|
+
localStorage.removeItem("handover_admin_user");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getErrorCode(error: unknown): string | null {
|
|
39
|
+
if (typeof error !== "object" || error === null || !("code" in error)) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const maybeCode = (error as { code?: unknown }).code;
|
|
44
|
+
return typeof maybeCode === "string" ? maybeCode : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default function AdminDashboardPage() {
|
|
48
|
+
const router = useRouter();
|
|
49
|
+
const { handover, text, images, isLoading, refresh } = useHandover();
|
|
50
|
+
const [activeTab, setActiveTab] = useState<"text" | "images" | "users">("text");
|
|
51
|
+
const [sessionUser, setSessionUser] = useState<SessionUser | null>(null);
|
|
52
|
+
const [isSessionLoading, setIsSessionLoading] = useState(true);
|
|
53
|
+
|
|
54
|
+
const [isContentModalOpen, setIsContentModalOpen] = useState(false);
|
|
55
|
+
const [editingKey, setEditingKey] = useState("");
|
|
56
|
+
const [editingValue, setEditingValue] = useState("");
|
|
57
|
+
const [isSavingContent, setIsSavingContent] = useState(false);
|
|
58
|
+
const [isNewContent, setIsNewContent] = useState(false);
|
|
59
|
+
|
|
60
|
+
const [deleteTarget, setDeleteTarget] = useState<{ type: "text" | "image"; id: string } | null>(null);
|
|
61
|
+
const [isDeleting, setIsDeleting] = useState(false);
|
|
62
|
+
|
|
63
|
+
const [file, setFile] = useState<File | null>(null);
|
|
64
|
+
const [altText, setAltText] = useState("");
|
|
65
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
66
|
+
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
|
|
67
|
+
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
68
|
+
const [globalError, setGlobalError] = useState<string | null>(null);
|
|
69
|
+
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
|
70
|
+
|
|
71
|
+
const [users, setUsers] = useState<AdminUser[]>([]);
|
|
72
|
+
const [isUsersLoading, setIsUsersLoading] = useState(false);
|
|
73
|
+
const [isUserModalOpen, setIsUserModalOpen] = useState(false);
|
|
74
|
+
const [newUsername, setNewUsername] = useState("");
|
|
75
|
+
const [newPassword, setNewPassword] = useState("");
|
|
76
|
+
const [newRole, setNewRole] = useState<"admin" | "editor" | "viewer">("editor");
|
|
77
|
+
const [isCreatingUser, setIsCreatingUser] = useState(false);
|
|
78
|
+
const [userError, setUserError] = useState<string | null>(null);
|
|
79
|
+
const [resetPasswordTarget, setResetPasswordTarget] = useState<{ id: string; username: string } | null>(null);
|
|
80
|
+
const [resetPasswordValue, setResetPasswordValue] = useState("");
|
|
81
|
+
const [confirmResetPasswordValue, setConfirmResetPasswordValue] = useState("");
|
|
82
|
+
const [isResettingUserPassword, setIsResettingUserPassword] = useState(false);
|
|
83
|
+
const [resetTokenResult, setResetTokenResult] = useState<{ username: string; token: string; expiresAt: number } | null>(null);
|
|
84
|
+
const [deleteUserTarget, setDeleteUserTarget] = useState<{ id: string; username: string } | null>(null);
|
|
85
|
+
const [isDeletingUser, setIsDeletingUser] = useState(false);
|
|
86
|
+
const resetTokenCloseRef = useRef<HTMLButtonElement | null>(null);
|
|
87
|
+
|
|
88
|
+
const canManageUsers = useMemo(() => {
|
|
89
|
+
return sessionUser?.role === "owner" || sessionUser?.role === "admin";
|
|
90
|
+
}, [sessionUser]);
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
let canceled = false;
|
|
94
|
+
|
|
95
|
+
async function verifySession() {
|
|
96
|
+
const token = getSessionToken();
|
|
97
|
+
if (!token) {
|
|
98
|
+
router.push("/admin/login");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const session = await handover.getAdminSession(token);
|
|
104
|
+
if (!session.isValid || !session.user) {
|
|
105
|
+
clearSession();
|
|
106
|
+
router.push("/admin/login");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!canceled) {
|
|
111
|
+
setSessionUser(session.user as SessionUser);
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
clearSession();
|
|
115
|
+
router.push("/admin/login");
|
|
116
|
+
} finally {
|
|
117
|
+
if (!canceled) {
|
|
118
|
+
setIsSessionLoading(false);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
verifySession();
|
|
124
|
+
|
|
125
|
+
return () => {
|
|
126
|
+
canceled = true;
|
|
127
|
+
};
|
|
128
|
+
}, [handover, router]);
|
|
129
|
+
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (activeTab !== "users" || !canManageUsers) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let canceled = false;
|
|
136
|
+
async function loadUsers() {
|
|
137
|
+
const token = getSessionToken();
|
|
138
|
+
if (!token) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
setIsUsersLoading(true);
|
|
143
|
+
setUserError(null);
|
|
144
|
+
try {
|
|
145
|
+
const result = await handover.listAdminUsers(token);
|
|
146
|
+
if (!canceled) {
|
|
147
|
+
setUsers(result as AdminUser[]);
|
|
148
|
+
}
|
|
149
|
+
} catch (error: unknown) {
|
|
150
|
+
const code = getErrorCode(error);
|
|
151
|
+
if (!canceled) {
|
|
152
|
+
if (code === "FORBIDDEN" || code === "UNAUTHORIZED") {
|
|
153
|
+
setUserError("You do not have permission to manage users.");
|
|
154
|
+
} else {
|
|
155
|
+
setUserError("Failed to load users.");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} finally {
|
|
159
|
+
if (!canceled) {
|
|
160
|
+
setIsUsersLoading(false);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
loadUsers();
|
|
166
|
+
|
|
167
|
+
return () => {
|
|
168
|
+
canceled = true;
|
|
169
|
+
};
|
|
170
|
+
}, [activeTab, canManageUsers, handover]);
|
|
171
|
+
|
|
172
|
+
const handleUnauthorizedSession = () => {
|
|
173
|
+
setGlobalError("Your admin session expired. Please log in again.");
|
|
174
|
+
clearSession();
|
|
175
|
+
router.push("/admin/login");
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const handleLogout = async () => {
|
|
179
|
+
const token = getSessionToken();
|
|
180
|
+
if (token) {
|
|
181
|
+
try {
|
|
182
|
+
await handover.logoutAdmin(token);
|
|
183
|
+
} catch {
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
clearSession();
|
|
187
|
+
router.push("/admin/login");
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const openNewContentModal = () => {
|
|
191
|
+
setEditingKey("");
|
|
192
|
+
setEditingValue("");
|
|
193
|
+
setIsNewContent(true);
|
|
194
|
+
setIsContentModalOpen(true);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const openEditContentModal = (key: string, value: unknown) => {
|
|
198
|
+
setEditingKey(key);
|
|
199
|
+
setEditingValue(String(value));
|
|
200
|
+
setIsNewContent(false);
|
|
201
|
+
setIsContentModalOpen(true);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const handleSaveContent = async () => {
|
|
205
|
+
if (!editingKey || !editingValue) return;
|
|
206
|
+
const token = getSessionToken();
|
|
207
|
+
if (!token) {
|
|
208
|
+
handleUnauthorizedSession();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
setIsSavingContent(true);
|
|
213
|
+
setGlobalError(null);
|
|
214
|
+
try {
|
|
215
|
+
await handover.updateText(editingKey, editingValue, token);
|
|
216
|
+
await refresh();
|
|
217
|
+
setIsContentModalOpen(false);
|
|
218
|
+
setSuccessMessage(isNewContent ? "✅ Content element added successfully!" : "✅ Content updated successfully!");
|
|
219
|
+
setTimeout(() => setSuccessMessage(null), 4000);
|
|
220
|
+
} catch (error: unknown) {
|
|
221
|
+
const code = getErrorCode(error);
|
|
222
|
+
if (code === "UNAUTHORIZED" || code === "SESSION_EXPIRED") {
|
|
223
|
+
handleUnauthorizedSession();
|
|
224
|
+
} else {
|
|
225
|
+
setGlobalError("Failed to save content.");
|
|
226
|
+
}
|
|
227
|
+
} finally {
|
|
228
|
+
setIsSavingContent(false);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const confirmDelete = (type: "text" | "image", id: string) => {
|
|
233
|
+
setDeleteTarget({ type, id });
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const handleDelete = async () => {
|
|
237
|
+
if (!deleteTarget) return;
|
|
238
|
+
const token = getSessionToken();
|
|
239
|
+
if (!token) {
|
|
240
|
+
handleUnauthorizedSession();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
setIsDeleting(true);
|
|
245
|
+
setGlobalError(null);
|
|
246
|
+
try {
|
|
247
|
+
if (deleteTarget.type === "text") {
|
|
248
|
+
await handover.deleteText(deleteTarget.id, token);
|
|
249
|
+
} else {
|
|
250
|
+
await handover.deleteImage(deleteTarget.id, token);
|
|
251
|
+
}
|
|
252
|
+
await refresh();
|
|
253
|
+
setDeleteTarget(null);
|
|
254
|
+
} catch (error: unknown) {
|
|
255
|
+
const code = getErrorCode(error);
|
|
256
|
+
if (code === "UNAUTHORIZED" || code === "SESSION_EXPIRED") {
|
|
257
|
+
handleUnauthorizedSession();
|
|
258
|
+
} else {
|
|
259
|
+
setGlobalError("Failed to delete this item.");
|
|
260
|
+
}
|
|
261
|
+
} finally {
|
|
262
|
+
setIsDeleting(false);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const handleUpload = async () => {
|
|
267
|
+
if (!file) return;
|
|
268
|
+
if (!file.type.startsWith("image/")) {
|
|
269
|
+
setUploadError("Please select an image file to upload.");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const token = getSessionToken();
|
|
274
|
+
if (!token) {
|
|
275
|
+
handleUnauthorizedSession();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
setIsUploading(true);
|
|
280
|
+
setUploadError(null);
|
|
281
|
+
setGlobalError(null);
|
|
282
|
+
try {
|
|
283
|
+
await handover.uploadImage(file, token, altText);
|
|
284
|
+
await refresh();
|
|
285
|
+
setFile(null);
|
|
286
|
+
setAltText("");
|
|
287
|
+
setIsUploadModalOpen(false);
|
|
288
|
+
setSuccessMessage("📸 Image uploaded successfully!");
|
|
289
|
+
setTimeout(() => setSuccessMessage(null), 4000);
|
|
290
|
+
} catch (error: unknown) {
|
|
291
|
+
const code = getErrorCode(error);
|
|
292
|
+
if (code === "STORAGE_LIMIT_EXCEEDED") {
|
|
293
|
+
setUploadError("You've reached your storage limit. Please upgrade to Pro or delete some files to continue.");
|
|
294
|
+
} else if (code === "INVALID_IMAGE_TYPE") {
|
|
295
|
+
setUploadError("That file type isn't supported. Please upload a JPG, PNG, GIF, or WebP image.");
|
|
296
|
+
} else if (code === "AUTH_RATE_LIMITED") {
|
|
297
|
+
setUploadError("Too many attempts. Please wait a moment and try again.");
|
|
298
|
+
} else if (code === "UNAUTHORIZED" || code === "SESSION_EXPIRED") {
|
|
299
|
+
handleUnauthorizedSession();
|
|
300
|
+
} else {
|
|
301
|
+
setUploadError("Unable to upload image. Please try again.");
|
|
302
|
+
}
|
|
303
|
+
} finally {
|
|
304
|
+
setIsUploading(false);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const handleCreateUser = async () => {
|
|
309
|
+
const token = getSessionToken();
|
|
310
|
+
if (!token) {
|
|
311
|
+
handleUnauthorizedSession();
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
setIsCreatingUser(true);
|
|
316
|
+
setUserError(null);
|
|
317
|
+
try {
|
|
318
|
+
await handover.createAdminUser(token, {
|
|
319
|
+
username: newUsername,
|
|
320
|
+
password: newPassword,
|
|
321
|
+
role: newRole,
|
|
322
|
+
});
|
|
323
|
+
const refreshedUsers = await handover.listAdminUsers(token);
|
|
324
|
+
setUsers(refreshedUsers as AdminUser[]);
|
|
325
|
+
setNewUsername("");
|
|
326
|
+
setNewPassword("");
|
|
327
|
+
setNewRole("editor");
|
|
328
|
+
setIsUserModalOpen(false);
|
|
329
|
+
setSuccessMessage(`🎉 User "${newUsername}" created successfully!`);
|
|
330
|
+
setTimeout(() => setSuccessMessage(null), 4000);
|
|
331
|
+
} catch (error: unknown) {
|
|
332
|
+
const code = getErrorCode(error);
|
|
333
|
+
if (code === "USERNAME_TAKEN") {
|
|
334
|
+
setUserError("That username is already in use. Please choose a different one.");
|
|
335
|
+
} else if (code === "FORBIDDEN") {
|
|
336
|
+
setUserError("You don't have permission to create users.");
|
|
337
|
+
} else if (code === "UNAUTHORIZED" || code === "SESSION_EXPIRED") {
|
|
338
|
+
handleUnauthorizedSession();
|
|
339
|
+
} else {
|
|
340
|
+
setUserError("Unable to create user. Please try again.");
|
|
341
|
+
}
|
|
342
|
+
} finally {
|
|
343
|
+
setIsCreatingUser(false);
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const handleToggleUserStatus = async (userId: string, nextStatus: AdminStatus) => {
|
|
348
|
+
const token = getSessionToken();
|
|
349
|
+
if (!token) {
|
|
350
|
+
handleUnauthorizedSession();
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
setUserError(null);
|
|
355
|
+
try {
|
|
356
|
+
await handover.updateAdminUser(token, {
|
|
357
|
+
userId,
|
|
358
|
+
status: nextStatus,
|
|
359
|
+
});
|
|
360
|
+
const refreshedUsers = await handover.listAdminUsers(token);
|
|
361
|
+
setUsers(refreshedUsers as AdminUser[]);
|
|
362
|
+
} catch (error: unknown) {
|
|
363
|
+
const code = getErrorCode(error);
|
|
364
|
+
if (code === "FORBIDDEN") {
|
|
365
|
+
setUserError("You don't have permission to update users.");
|
|
366
|
+
} else if (code === "UNAUTHORIZED" || code === "SESSION_EXPIRED") {
|
|
367
|
+
handleUnauthorizedSession();
|
|
368
|
+
} else {
|
|
369
|
+
setUserError("Unable to update user. Please try again.");
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const handleDeleteUser = async () => {
|
|
375
|
+
if (!deleteUserTarget) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const token = getSessionToken();
|
|
380
|
+
if (!token) {
|
|
381
|
+
handleUnauthorizedSession();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
setIsDeletingUser(true);
|
|
386
|
+
setUserError(null);
|
|
387
|
+
try {
|
|
388
|
+
await handover.deleteAdminUser(token, deleteUserTarget.id);
|
|
389
|
+
const refreshedUsers = await handover.listAdminUsers(token);
|
|
390
|
+
setUsers(refreshedUsers as AdminUser[]);
|
|
391
|
+
setDeleteUserTarget(null);
|
|
392
|
+
} catch (error: unknown) {
|
|
393
|
+
const code = getErrorCode(error);
|
|
394
|
+
if (code === "CANNOT_DELETE_OWNER") {
|
|
395
|
+
setUserError("The owner account can't be deleted.");
|
|
396
|
+
} else if (code === "FORBIDDEN") {
|
|
397
|
+
setUserError("You don't have permission to delete users.");
|
|
398
|
+
} else if (code === "UNAUTHORIZED" || code === "SESSION_EXPIRED") {
|
|
399
|
+
handleUnauthorizedSession();
|
|
400
|
+
} else {
|
|
401
|
+
setUserError("Unable to delete user. Please try again.");
|
|
402
|
+
}
|
|
403
|
+
} finally {
|
|
404
|
+
setIsDeletingUser(false);
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const handleResetUserPassword = async () => {
|
|
409
|
+
if (!resetPasswordTarget) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (!resetPasswordValue.trim()) {
|
|
414
|
+
setUserError("Password cannot be empty.");
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (resetPasswordValue !== confirmResetPasswordValue) {
|
|
419
|
+
setUserError("Passwords do not match.");
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const token = getSessionToken();
|
|
424
|
+
if (!token) {
|
|
425
|
+
handleUnauthorizedSession();
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
setIsResettingUserPassword(true);
|
|
430
|
+
setUserError(null);
|
|
431
|
+
try {
|
|
432
|
+
await handover.resetAdminUserPassword(token, {
|
|
433
|
+
userId: resetPasswordTarget.id,
|
|
434
|
+
newPassword: resetPasswordValue,
|
|
435
|
+
});
|
|
436
|
+
const refreshedUsers = await handover.listAdminUsers(token);
|
|
437
|
+
setUsers(refreshedUsers as AdminUser[]);
|
|
438
|
+
setResetPasswordTarget(null);
|
|
439
|
+
setResetPasswordValue("");
|
|
440
|
+
setConfirmResetPasswordValue("");
|
|
441
|
+
} catch (error: unknown) {
|
|
442
|
+
const code = getErrorCode(error);
|
|
443
|
+
if (code === "FORBIDDEN") {
|
|
444
|
+
setUserError("You do not have permission for that action.");
|
|
445
|
+
} else if (code === "PASSWORD_EMPTY" || code === "PASSWORD_TOO_SHORT" || code === "PASSWORD_TOO_LONG" || code === "PASSWORD_COMPLEXITY_REQUIREMENTS") {
|
|
446
|
+
setUserError("Password must be 10-128 chars and include uppercase, lowercase, and a number.");
|
|
447
|
+
} else if (code === "UNAUTHORIZED" || code === "SESSION_EXPIRED") {
|
|
448
|
+
handleUnauthorizedSession();
|
|
449
|
+
} else {
|
|
450
|
+
setUserError("Failed to reset password.");
|
|
451
|
+
}
|
|
452
|
+
} finally {
|
|
453
|
+
setIsResettingUserPassword(false);
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const handleCreateResetToken = async (userId: string, username: string) => {
|
|
458
|
+
const token = getSessionToken();
|
|
459
|
+
if (!token) {
|
|
460
|
+
handleUnauthorizedSession();
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
setUserError(null);
|
|
465
|
+
try {
|
|
466
|
+
const result = await handover.createAdminUserResetToken(token, userId);
|
|
467
|
+
setResetTokenResult({
|
|
468
|
+
username,
|
|
469
|
+
token: result.token,
|
|
470
|
+
expiresAt: result.expiresAt,
|
|
471
|
+
});
|
|
472
|
+
} catch (error: unknown) {
|
|
473
|
+
const code = getErrorCode(error);
|
|
474
|
+
if (code === "FORBIDDEN") {
|
|
475
|
+
setUserError("You do not have permission for that action.");
|
|
476
|
+
} else if (code === "UNAUTHORIZED" || code === "SESSION_EXPIRED") {
|
|
477
|
+
handleUnauthorizedSession();
|
|
478
|
+
} else {
|
|
479
|
+
setUserError("Failed to create reset token.");
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
if (isSessionLoading) {
|
|
485
|
+
return (
|
|
486
|
+
<div className="min-h-screen admin-shell flex items-center justify-center p-6">
|
|
487
|
+
<div className="surface-card max-w-md w-full p-6 text-center text-slate-600">Verifying admin session...</div>
|
|
488
|
+
</div>
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return (
|
|
493
|
+
<div className="min-h-screen admin-shell">
|
|
494
|
+
<nav className="bg-white/90 backdrop-blur border-b border-slate-200 px-4 sm:px-8 py-4 flex justify-between items-center sticky top-0 z-10 shadow-sm">
|
|
495
|
+
<div className="flex items-center gap-3">
|
|
496
|
+
<div className="bg-gradient-to-br from-teal-700 to-slate-900 text-white w-8 h-8 rounded-lg flex items-center justify-center font-bold">{getBrandInitial()}</div>
|
|
497
|
+
<div>
|
|
498
|
+
<h1 className="text-xl font-bold text-slate-900 hidden sm:block">{branding.adminTitle}</h1>
|
|
499
|
+
{sessionUser && <p className="hidden sm:block text-xs text-slate-500">Signed in as {sessionUser.username} ({sessionUser.role})</p>}
|
|
500
|
+
</div>
|
|
501
|
+
</div>
|
|
502
|
+
<button
|
|
503
|
+
onClick={handleLogout}
|
|
504
|
+
className="focus-ring flex items-center gap-2 text-sm font-medium text-slate-600 hover:text-red-700 transition-colors"
|
|
505
|
+
>
|
|
506
|
+
<LogOut className="w-4 h-4" />
|
|
507
|
+
<span className="hidden sm:inline">Logout</span>
|
|
508
|
+
</button>
|
|
509
|
+
</nav>
|
|
510
|
+
|
|
511
|
+
<main className="max-w-6xl mx-auto p-4 sm:p-8 space-y-8">
|
|
512
|
+
<section className="surface-card p-4 sm:p-6">
|
|
513
|
+
<p className="section-label">{branding.siteName}</p>
|
|
514
|
+
<h2 className="mt-2 text-xl sm:text-2xl font-semibold text-slate-900">Admin Operations</h2>
|
|
515
|
+
<p className="mt-2 text-sm text-slate-600">
|
|
516
|
+
Manage content, media, and authorized admin users for this project.
|
|
517
|
+
</p>
|
|
518
|
+
</section>
|
|
519
|
+
|
|
520
|
+
{globalError && (
|
|
521
|
+
<div className="error-banner rounded-xl px-4 py-3 text-sm">{globalError}</div>
|
|
522
|
+
)}
|
|
523
|
+
|
|
524
|
+
{successMessage && (
|
|
525
|
+
<div className="rounded-xl px-4 py-3 text-sm font-medium bg-green-50 border border-green-200 text-green-800 flex items-center gap-2" role="status" aria-live="polite">
|
|
526
|
+
{successMessage}
|
|
527
|
+
</div>
|
|
528
|
+
)}
|
|
529
|
+
|
|
530
|
+
<div className="surface-card p-3 sm:p-4">
|
|
531
|
+
<p className="section-label mb-2">Workspace</p>
|
|
532
|
+
<div
|
|
533
|
+
className="flex gap-4 border-b border-slate-200 overflow-x-auto"
|
|
534
|
+
role="tablist"
|
|
535
|
+
aria-label="Content management tabs"
|
|
536
|
+
onKeyDown={(e) => {
|
|
537
|
+
const tabs: Array<"text" | "images" | "users"> = ["text", "images", "users"];
|
|
538
|
+
const currentIndex = tabs.indexOf(activeTab);
|
|
539
|
+
|
|
540
|
+
if (e.key === "ArrowRight") {
|
|
541
|
+
e.preventDefault();
|
|
542
|
+
const nextIndex = (currentIndex + 1) % tabs.length;
|
|
543
|
+
setActiveTab(tabs[nextIndex]);
|
|
544
|
+
} else if (e.key === "ArrowLeft") {
|
|
545
|
+
e.preventDefault();
|
|
546
|
+
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
|
547
|
+
setActiveTab(tabs[prevIndex]);
|
|
548
|
+
} else if (e.key === "Home") {
|
|
549
|
+
e.preventDefault();
|
|
550
|
+
setActiveTab(tabs[0]);
|
|
551
|
+
} else if (e.key === "End") {
|
|
552
|
+
e.preventDefault();
|
|
553
|
+
setActiveTab(tabs[tabs.length - 1]);
|
|
554
|
+
}
|
|
555
|
+
}}
|
|
556
|
+
>
|
|
557
|
+
<button
|
|
558
|
+
role="tab"
|
|
559
|
+
aria-selected={activeTab === "text"}
|
|
560
|
+
aria-controls="panel-text"
|
|
561
|
+
id="tab-text"
|
|
562
|
+
tabIndex={activeTab === "text" ? 0 : -1}
|
|
563
|
+
onClick={() => setActiveTab("text")}
|
|
564
|
+
className={clsx(
|
|
565
|
+
"focus-ring pb-3 px-2 font-medium text-sm flex items-center gap-2 transition-colors border-b-2 whitespace-nowrap",
|
|
566
|
+
activeTab === "text"
|
|
567
|
+
? "border-teal-700 text-teal-700"
|
|
568
|
+
: "border-transparent text-slate-500 hover:text-slate-900"
|
|
569
|
+
)}
|
|
570
|
+
>
|
|
571
|
+
<Type className="w-4 h-4" />
|
|
572
|
+
Content
|
|
573
|
+
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600">{Object.keys(text).length}</span>
|
|
574
|
+
</button>
|
|
575
|
+
<button
|
|
576
|
+
role="tab"
|
|
577
|
+
aria-selected={activeTab === "images"}
|
|
578
|
+
aria-controls="panel-images"
|
|
579
|
+
id="tab-images"
|
|
580
|
+
tabIndex={activeTab === "images" ? 0 : -1}
|
|
581
|
+
onClick={() => setActiveTab("images")}
|
|
582
|
+
className={clsx(
|
|
583
|
+
"focus-ring pb-3 px-2 font-medium text-sm flex items-center gap-2 transition-colors border-b-2 whitespace-nowrap",
|
|
584
|
+
activeTab === "images"
|
|
585
|
+
? "border-teal-700 text-teal-700"
|
|
586
|
+
: "border-transparent text-slate-500 hover:text-slate-900"
|
|
587
|
+
)}
|
|
588
|
+
>
|
|
589
|
+
<ImageIcon className="w-4 h-4" />
|
|
590
|
+
Media
|
|
591
|
+
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600">{images.length}</span>
|
|
592
|
+
</button>
|
|
593
|
+
<button
|
|
594
|
+
role="tab"
|
|
595
|
+
aria-selected={activeTab === "users"}
|
|
596
|
+
aria-controls="panel-users"
|
|
597
|
+
id="tab-users"
|
|
598
|
+
tabIndex={activeTab === "users" ? 0 : -1}
|
|
599
|
+
onClick={() => setActiveTab("users")}
|
|
600
|
+
className={clsx(
|
|
601
|
+
"focus-ring pb-3 px-2 font-medium text-sm flex items-center gap-2 transition-colors border-b-2 whitespace-nowrap",
|
|
602
|
+
activeTab === "users"
|
|
603
|
+
? "border-teal-700 text-teal-700"
|
|
604
|
+
: "border-transparent text-slate-500 hover:text-slate-900"
|
|
605
|
+
)}
|
|
606
|
+
>
|
|
607
|
+
<Users className="w-4 h-4" />
|
|
608
|
+
User Management
|
|
609
|
+
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600">{users.length || "-"}</span>
|
|
610
|
+
</button>
|
|
611
|
+
</div>
|
|
612
|
+
</div>
|
|
613
|
+
|
|
614
|
+
{activeTab === "text" && (
|
|
615
|
+
<div
|
|
616
|
+
role="tabpanel"
|
|
617
|
+
id="panel-text"
|
|
618
|
+
aria-labelledby="tab-text"
|
|
619
|
+
className="space-y-6"
|
|
620
|
+
>
|
|
621
|
+
<div className="flex justify-between items-center">
|
|
622
|
+
<h2 className="text-lg font-semibold text-slate-900">Text Content</h2>
|
|
623
|
+
<button
|
|
624
|
+
onClick={openNewContentModal}
|
|
625
|
+
className="focus-ring bg-slate-900 text-white px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2 hover:bg-slate-800 transition-colors"
|
|
626
|
+
>
|
|
627
|
+
<Plus className="w-4 h-4" />
|
|
628
|
+
Add Element
|
|
629
|
+
</button>
|
|
630
|
+
</div>
|
|
631
|
+
|
|
632
|
+
<div className="surface-card overflow-hidden">
|
|
633
|
+
<div className="grid grid-cols-12 gap-4 p-4 border-b border-slate-200 bg-slate-50/70 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
|
634
|
+
<div className="col-span-4 sm:col-span-3">Key</div>
|
|
635
|
+
<div className="col-span-6 sm:col-span-7">Value</div>
|
|
636
|
+
<div className="col-span-2 text-right">Actions</div>
|
|
637
|
+
</div>
|
|
638
|
+
<div className="divide-y divide-slate-100">
|
|
639
|
+
{isLoading ? (
|
|
640
|
+
<div className="p-8 text-center text-slate-500 text-sm">Loading content...</div>
|
|
641
|
+
) : Object.keys(text).length === 0 ? (
|
|
642
|
+
<div className="p-8 sm:p-12 text-center space-y-5">
|
|
643
|
+
<div className="w-16 h-16 bg-teal-50 rounded-full flex items-center justify-center mx-auto border border-teal-100">
|
|
644
|
+
<FileText className="w-8 h-8 text-teal-700" />
|
|
645
|
+
</div>
|
|
646
|
+
<div className="space-y-2">
|
|
647
|
+
<h3 className="text-lg font-semibold text-slate-900">No content elements yet</h3>
|
|
648
|
+
<p className="text-sm text-slate-600 max-w-md mx-auto">
|
|
649
|
+
Content elements are editable text fields that you can update without code changes.
|
|
650
|
+
</p>
|
|
651
|
+
</div>
|
|
652
|
+
<div className="bg-slate-50 rounded-lg p-4 max-w-md mx-auto text-left border border-slate-100">
|
|
653
|
+
<p className="text-xs font-medium text-slate-700 mb-2">How it works:</p>
|
|
654
|
+
<ol className="space-y-2 text-xs text-slate-600">
|
|
655
|
+
<li className="flex items-start gap-2">
|
|
656
|
+
<span className="flex-shrink-0 w-4 h-4 rounded-full bg-teal-100 text-teal-700 flex items-center justify-center text-[10px] font-medium">1</span>
|
|
657
|
+
<span>Click "Add Content" to create a new editable field</span>
|
|
658
|
+
</li>
|
|
659
|
+
<li className="flex items-start gap-2">
|
|
660
|
+
<span className="flex-shrink-0 w-4 h-4 rounded-full bg-teal-100 text-teal-700 flex items-center justify-center text-[10px] font-medium">2</span>
|
|
661
|
+
<span>Use the key in your code: <code className="text-[10px] bg-white px-1 py-0.5 rounded border border-slate-200">sdk.getContent('hero_title')</code></span>
|
|
662
|
+
</li>
|
|
663
|
+
<li className="flex items-start gap-2">
|
|
664
|
+
<span className="flex-shrink-0 w-4 h-4 rounded-full bg-teal-100 text-teal-700 flex items-center justify-center text-[10px] font-medium">3</span>
|
|
665
|
+
<span>Edit the value here anytime—changes appear instantly</span>
|
|
666
|
+
</li>
|
|
667
|
+
</ol>
|
|
668
|
+
</div>
|
|
669
|
+
<button
|
|
670
|
+
onClick={openNewContentModal}
|
|
671
|
+
className="focus-ring inline-flex items-center gap-2 rounded-lg bg-teal-700 px-4 py-2.5 text-sm font-medium text-white hover:bg-teal-800"
|
|
672
|
+
>
|
|
673
|
+
<Plus className="w-4 h-4" />
|
|
674
|
+
Add Content
|
|
675
|
+
</button>
|
|
676
|
+
</div>
|
|
677
|
+
) : (
|
|
678
|
+
Object.entries(text).map(([key, value]) => (
|
|
679
|
+
<div key={key} className="grid grid-cols-12 gap-4 p-4 items-center hover:bg-slate-50 transition-colors group">
|
|
680
|
+
<div className="col-span-4 sm:col-span-3 font-mono text-xs text-teal-700 truncate bg-teal-50 px-2 py-1 rounded w-fit max-w-full">
|
|
681
|
+
{key}
|
|
682
|
+
</div>
|
|
683
|
+
<div className="col-span-6 sm:col-span-7 text-sm text-slate-700 truncate font-medium">
|
|
684
|
+
{String(value)}
|
|
685
|
+
</div>
|
|
686
|
+
<div className="col-span-2 flex items-center justify-end gap-2">
|
|
687
|
+
<button
|
|
688
|
+
onClick={() => openEditContentModal(key, value)}
|
|
689
|
+
className="focus-ring p-1.5 text-slate-400 hover:text-teal-700 hover:bg-teal-50 rounded transition-colors"
|
|
690
|
+
title="Edit"
|
|
691
|
+
>
|
|
692
|
+
<Pencil className="w-4 h-4" />
|
|
693
|
+
</button>
|
|
694
|
+
<button
|
|
695
|
+
onClick={() => {
|
|
696
|
+
navigator.clipboard.writeText(`handover.text['${key}']`);
|
|
697
|
+
}}
|
|
698
|
+
className="focus-ring p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded transition-colors"
|
|
699
|
+
title="Copy Code"
|
|
700
|
+
>
|
|
701
|
+
<Copy className="w-4 h-4" />
|
|
702
|
+
</button>
|
|
703
|
+
<button
|
|
704
|
+
onClick={() => confirmDelete("text", key)}
|
|
705
|
+
className="focus-ring p-1.5 text-slate-400 hover:text-red-700 hover:bg-red-50 rounded transition-colors"
|
|
706
|
+
title="Delete"
|
|
707
|
+
>
|
|
708
|
+
<Trash2 className="w-4 h-4" />
|
|
709
|
+
</button>
|
|
710
|
+
</div>
|
|
711
|
+
</div>
|
|
712
|
+
))
|
|
713
|
+
)}
|
|
714
|
+
</div>
|
|
715
|
+
</div>
|
|
716
|
+
</div>
|
|
717
|
+
)}
|
|
718
|
+
|
|
719
|
+
{activeTab === "images" && (
|
|
720
|
+
<div
|
|
721
|
+
role="tabpanel"
|
|
722
|
+
id="panel-images"
|
|
723
|
+
aria-labelledby="tab-images"
|
|
724
|
+
className="space-y-6"
|
|
725
|
+
>
|
|
726
|
+
<div className="flex justify-between items-center">
|
|
727
|
+
<h2 className="text-lg font-semibold text-slate-900">Image Library</h2>
|
|
728
|
+
<button
|
|
729
|
+
onClick={() => setIsUploadModalOpen(true)}
|
|
730
|
+
className="focus-ring bg-teal-700 text-white px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2 hover:bg-teal-800 transition-colors"
|
|
731
|
+
>
|
|
732
|
+
<Upload className="w-4 h-4" />
|
|
733
|
+
Upload Image
|
|
734
|
+
</button>
|
|
735
|
+
</div>
|
|
736
|
+
|
|
737
|
+
{images.length === 0 ? (
|
|
738
|
+
<div className="surface-card p-8 sm:p-12 text-center space-y-5">
|
|
739
|
+
<div className="w-16 h-16 bg-teal-50 rounded-full flex items-center justify-center mx-auto border border-teal-100">
|
|
740
|
+
<ImageIcon className="w-8 h-8 text-teal-700" />
|
|
741
|
+
</div>
|
|
742
|
+
<div className="space-y-2">
|
|
743
|
+
<h3 className="text-lg font-semibold text-slate-900">No uploaded images yet</h3>
|
|
744
|
+
<p className="text-sm text-slate-600 max-w-md mx-auto">
|
|
745
|
+
Upload images to manage your media library and use them in your site.
|
|
746
|
+
</p>
|
|
747
|
+
</div>
|
|
748
|
+
<div className="bg-slate-50 rounded-lg p-4 max-w-md mx-auto text-left border border-slate-100">
|
|
749
|
+
<p className="text-xs font-medium text-slate-700 mb-2">Getting started:</p>
|
|
750
|
+
<ol className="space-y-2 text-xs text-slate-600">
|
|
751
|
+
<li className="flex items-start gap-2">
|
|
752
|
+
<span className="flex-shrink-0 w-4 h-4 rounded-full bg-teal-100 text-teal-700 flex items-center justify-center text-[10px] font-medium">1</span>
|
|
753
|
+
<span>Click "Upload Image" and select a file</span>
|
|
754
|
+
</li>
|
|
755
|
+
<li className="flex items-start gap-2">
|
|
756
|
+
<span className="flex-shrink-0 w-4 h-4 rounded-full bg-teal-100 text-teal-700 flex items-center justify-center text-[10px] font-medium">2</span>
|
|
757
|
+
<span>Copy the image ID or URL for use in your code</span>
|
|
758
|
+
</li>
|
|
759
|
+
<li className="flex items-start gap-2">
|
|
760
|
+
<span className="flex-shrink-0 w-4 h-4 rounded-full bg-teal-100 text-teal-700 flex items-center justify-center text-[10px] font-medium">3</span>
|
|
761
|
+
<span>Use <code className="text-[10px] bg-white px-1 py-0.5 rounded border border-slate-200">sdk.getImage(id)</code> to display it</span>
|
|
762
|
+
</li>
|
|
763
|
+
</ol>
|
|
764
|
+
</div>
|
|
765
|
+
<button
|
|
766
|
+
onClick={() => setIsUploadModalOpen(true)}
|
|
767
|
+
className="focus-ring inline-flex items-center gap-2 rounded-lg bg-teal-700 px-4 py-2.5 text-sm font-medium text-white hover:bg-teal-800"
|
|
768
|
+
>
|
|
769
|
+
<Upload className="w-4 h-4" />
|
|
770
|
+
Upload Image
|
|
771
|
+
</button>
|
|
772
|
+
</div>
|
|
773
|
+
) : (
|
|
774
|
+
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 sm:gap-6">
|
|
775
|
+
{images.map((img) => (
|
|
776
|
+
<div key={img._id} className="group relative bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden aspect-square">
|
|
777
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
778
|
+
<img src={img.url} alt={img.altText} className="w-full h-full object-cover" />
|
|
779
|
+
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4 translate-y-full group-hover:translate-y-0 transition-transform flex flex-col justify-end">
|
|
780
|
+
<p className="text-xs text-white/80 truncate mb-1">{img.altText || "No description"}</p>
|
|
781
|
+
<code className="text-[10px] text-gray-400 mb-2 truncate font-mono block">{img._id}</code>
|
|
782
|
+
|
|
783
|
+
<div className="grid grid-cols-2 gap-2">
|
|
784
|
+
<button
|
|
785
|
+
onClick={() => {
|
|
786
|
+
const code = `<img src="${img.url}" alt="${img.altText || ""}" />`;
|
|
787
|
+
navigator.clipboard.writeText(code);
|
|
788
|
+
}}
|
|
789
|
+
className="focus-ring bg-white/10 hover:bg-white/20 text-white text-xs py-1.5 rounded backdrop-blur-sm border border-white/20 flex items-center justify-center"
|
|
790
|
+
title="Copy Tag"
|
|
791
|
+
>
|
|
792
|
+
<Copy className="w-3 h-3" />
|
|
793
|
+
</button>
|
|
794
|
+
<button
|
|
795
|
+
onClick={() => confirmDelete("image", img._id)}
|
|
796
|
+
className="focus-ring bg-red-500/80 hover:bg-red-600 text-white text-xs py-1.5 rounded backdrop-blur-sm flex items-center justify-center"
|
|
797
|
+
title="Delete Image"
|
|
798
|
+
>
|
|
799
|
+
<Trash2 className="w-3 h-3" />
|
|
800
|
+
</button>
|
|
801
|
+
</div>
|
|
802
|
+
</div>
|
|
803
|
+
</div>
|
|
804
|
+
))}
|
|
805
|
+
</div>
|
|
806
|
+
)}
|
|
807
|
+
</div>
|
|
808
|
+
)}
|
|
809
|
+
|
|
810
|
+
{activeTab === "users" && (
|
|
811
|
+
<div
|
|
812
|
+
role="tabpanel"
|
|
813
|
+
id="panel-users"
|
|
814
|
+
aria-labelledby="tab-users"
|
|
815
|
+
className="space-y-6"
|
|
816
|
+
>
|
|
817
|
+
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
818
|
+
<div>
|
|
819
|
+
<h2 className="text-lg font-semibold text-slate-900">User Management</h2>
|
|
820
|
+
<p className="text-sm text-slate-600 mt-1">Manage who can access this admin backend and what they can do.</p>
|
|
821
|
+
</div>
|
|
822
|
+
{canManageUsers && (
|
|
823
|
+
<button
|
|
824
|
+
onClick={() => setIsUserModalOpen(true)}
|
|
825
|
+
className="focus-ring bg-slate-900 text-white px-4 py-2 rounded-lg text-sm font-medium inline-flex items-center gap-2 hover:bg-slate-800 transition-colors"
|
|
826
|
+
>
|
|
827
|
+
<Plus className="w-4 h-4" />
|
|
828
|
+
Add User
|
|
829
|
+
</button>
|
|
830
|
+
)}
|
|
831
|
+
</div>
|
|
832
|
+
|
|
833
|
+
{userError && <div className="error-banner rounded-xl px-4 py-3 text-sm">{userError}</div>}
|
|
834
|
+
|
|
835
|
+
{!canManageUsers ? (
|
|
836
|
+
<div className="surface-card p-8 text-center text-slate-600 text-sm space-y-3">
|
|
837
|
+
<Shield className="w-8 h-8 mx-auto text-slate-400" />
|
|
838
|
+
<p>Your role can view content and media but cannot manage admin users.</p>
|
|
839
|
+
</div>
|
|
840
|
+
) : (
|
|
841
|
+
<div className="surface-card overflow-hidden">
|
|
842
|
+
{/* Desktop table header - hidden on mobile */}
|
|
843
|
+
<div className="hidden md:grid grid-cols-12 gap-4 p-4 border-b border-slate-200 bg-slate-50/70 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
|
844
|
+
<div className="col-span-3">User</div>
|
|
845
|
+
<div className="col-span-2">Role</div>
|
|
846
|
+
<div className="col-span-3">Status</div>
|
|
847
|
+
<div className="col-span-2">Last Login</div>
|
|
848
|
+
<div className="col-span-2 text-right">Actions</div>
|
|
849
|
+
</div>
|
|
850
|
+
<div className="divide-y divide-slate-100">
|
|
851
|
+
{isUsersLoading ? (
|
|
852
|
+
<div className="p-8 text-center text-slate-500 text-sm">Loading users...</div>
|
|
853
|
+
) : users.length === 0 ? (
|
|
854
|
+
<div className="p-8 sm:p-12 text-center space-y-5">
|
|
855
|
+
<div className="w-16 h-16 bg-teal-50 rounded-full flex items-center justify-center mx-auto border border-teal-100">
|
|
856
|
+
<Users className="w-8 h-8 text-teal-700" />
|
|
857
|
+
</div>
|
|
858
|
+
<div className="space-y-2">
|
|
859
|
+
<h3 className="text-lg font-semibold text-slate-900">No admin users found</h3>
|
|
860
|
+
<p className="text-sm text-slate-600 max-w-md mx-auto">
|
|
861
|
+
Add team members to collaborate on content management.
|
|
862
|
+
</p>
|
|
863
|
+
</div>
|
|
864
|
+
<div className="bg-slate-50 rounded-lg p-4 max-w-md mx-auto text-left border border-slate-100">
|
|
865
|
+
<p className="text-xs font-medium text-slate-700 mb-2">User roles:</p>
|
|
866
|
+
<ul className="space-y-2 text-xs text-slate-600">
|
|
867
|
+
<li className="flex items-start gap-2">
|
|
868
|
+
<Shield className="w-3 h-3 mt-0.5 flex-shrink-0 text-teal-700" />
|
|
869
|
+
<span><strong className="text-slate-700">Owner</strong> – Full access to all features and settings</span>
|
|
870
|
+
</li>
|
|
871
|
+
<li className="flex items-start gap-2">
|
|
872
|
+
<Shield className="w-3 h-3 mt-0.5 flex-shrink-0 text-slate-500" />
|
|
873
|
+
<span><strong className="text-slate-700">Editor</strong> – Can manage content and images</span>
|
|
874
|
+
</li>
|
|
875
|
+
</ul>
|
|
876
|
+
</div>
|
|
877
|
+
<button
|
|
878
|
+
onClick={() => setIsUserModalOpen(true)}
|
|
879
|
+
className="focus-ring inline-flex items-center gap-2 rounded-lg bg-teal-700 px-4 py-2.5 text-sm font-medium text-white hover:bg-teal-800"
|
|
880
|
+
>
|
|
881
|
+
<Plus className="w-4 h-4" />
|
|
882
|
+
Add User
|
|
883
|
+
</button>
|
|
884
|
+
</div>
|
|
885
|
+
) : (
|
|
886
|
+
users.map((user) => {
|
|
887
|
+
const canDisable = user.role !== "owner" || sessionUser?.role === "owner";
|
|
888
|
+
const nextStatus: AdminStatus = user.status === "active" ? "disabled" : "active";
|
|
889
|
+
const statusLabel = user.status === "active" ? "Disable" : "Re-enable";
|
|
890
|
+
const canDelete = user.role !== "owner";
|
|
891
|
+
|
|
892
|
+
return (
|
|
893
|
+
<div key={user.id}>
|
|
894
|
+
{/* Desktop table row */}
|
|
895
|
+
<div className="hidden md:grid grid-cols-12 gap-4 p-4 items-center">
|
|
896
|
+
<div className="col-span-3">
|
|
897
|
+
<p className="text-sm font-medium text-slate-900">{user.username}</p>
|
|
898
|
+
</div>
|
|
899
|
+
<div className="col-span-2">
|
|
900
|
+
<span className="inline-flex rounded-full border border-slate-200 px-2 py-0.5 text-xs text-slate-700 capitalize">
|
|
901
|
+
{user.role}
|
|
902
|
+
</span>
|
|
903
|
+
</div>
|
|
904
|
+
<div className="col-span-3">
|
|
905
|
+
<span
|
|
906
|
+
className={clsx(
|
|
907
|
+
"inline-flex rounded-full px-2 py-0.5 text-xs capitalize",
|
|
908
|
+
user.status === "active"
|
|
909
|
+
? "bg-emerald-50 text-emerald-700"
|
|
910
|
+
: "bg-amber-50 text-amber-700"
|
|
911
|
+
)}
|
|
912
|
+
>
|
|
913
|
+
{user.status}
|
|
914
|
+
</span>
|
|
915
|
+
</div>
|
|
916
|
+
<div className="col-span-2 text-xs text-slate-500">
|
|
917
|
+
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : "Never"}
|
|
918
|
+
</div>
|
|
919
|
+
<div className="col-span-2 flex justify-end gap-2">
|
|
920
|
+
<button
|
|
921
|
+
onClick={() => handleCreateResetToken(user.id, user.username)}
|
|
922
|
+
disabled={!canDisable}
|
|
923
|
+
className="focus-ring p-1.5 text-slate-400 hover:text-sky-700 hover:bg-sky-50 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
924
|
+
title="Create one-time reset token"
|
|
925
|
+
>
|
|
926
|
+
<Copy className="w-4 h-4" />
|
|
927
|
+
</button>
|
|
928
|
+
<button
|
|
929
|
+
onClick={() => {
|
|
930
|
+
setResetPasswordTarget({ id: user.id, username: user.username });
|
|
931
|
+
setResetPasswordValue("");
|
|
932
|
+
setConfirmResetPasswordValue("");
|
|
933
|
+
}}
|
|
934
|
+
disabled={!canDisable}
|
|
935
|
+
className="focus-ring p-1.5 text-slate-400 hover:text-red-700 hover:bg-red-50 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
936
|
+
title="Reset password"
|
|
937
|
+
>
|
|
938
|
+
<UserX className="w-4 h-4" />
|
|
939
|
+
</button>
|
|
940
|
+
<button
|
|
941
|
+
onClick={() => handleToggleUserStatus(user.id, nextStatus)}
|
|
942
|
+
disabled={!canDisable}
|
|
943
|
+
className="focus-ring p-1.5 text-slate-400 hover:text-amber-700 hover:bg-amber-50 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
944
|
+
title={statusLabel}
|
|
945
|
+
>
|
|
946
|
+
<Shield className="w-4 h-4" />
|
|
947
|
+
</button>
|
|
948
|
+
<button
|
|
949
|
+
onClick={() => setDeleteUserTarget({ id: user.id, username: user.username })}
|
|
950
|
+
disabled={!canDelete}
|
|
951
|
+
className="focus-ring p-1.5 text-slate-400 hover:text-red-700 hover:bg-red-50 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
952
|
+
title="Delete user"
|
|
953
|
+
>
|
|
954
|
+
<Trash2 className="w-4 h-4" />
|
|
955
|
+
</button>
|
|
956
|
+
</div>
|
|
957
|
+
</div>
|
|
958
|
+
|
|
959
|
+
{/* Mobile card layout */}
|
|
960
|
+
<div className="md:hidden p-4 space-y-3">
|
|
961
|
+
<div className="flex items-start justify-between gap-3">
|
|
962
|
+
<div className="flex-1 min-w-0">
|
|
963
|
+
<p className="text-sm font-medium text-slate-900 truncate">{user.username}</p>
|
|
964
|
+
<div className="flex items-center gap-2 mt-1.5">
|
|
965
|
+
<span className="inline-flex rounded-full border border-slate-200 px-2 py-0.5 text-xs text-slate-700 capitalize">
|
|
966
|
+
{user.role}
|
|
967
|
+
</span>
|
|
968
|
+
<span
|
|
969
|
+
className={clsx(
|
|
970
|
+
"inline-flex rounded-full px-2 py-0.5 text-xs capitalize",
|
|
971
|
+
user.status === "active"
|
|
972
|
+
? "bg-emerald-50 text-emerald-700"
|
|
973
|
+
: "bg-amber-50 text-amber-700"
|
|
974
|
+
)}
|
|
975
|
+
>
|
|
976
|
+
{user.status}
|
|
977
|
+
</span>
|
|
978
|
+
</div>
|
|
979
|
+
</div>
|
|
980
|
+
</div>
|
|
981
|
+
<div className="text-xs text-slate-500">
|
|
982
|
+
Last login: {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : "Never"}
|
|
983
|
+
</div>
|
|
984
|
+
<div className="flex items-center gap-2 pt-2 border-t border-slate-100">
|
|
985
|
+
<button
|
|
986
|
+
onClick={() => handleCreateResetToken(user.id, user.username)}
|
|
987
|
+
disabled={!canDisable}
|
|
988
|
+
className="focus-ring flex-1 inline-flex items-center justify-center gap-1.5 rounded-lg border border-slate-300 px-3 py-2 text-xs font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
989
|
+
>
|
|
990
|
+
<Copy className="w-3.5 h-3.5" />
|
|
991
|
+
Reset Token
|
|
992
|
+
</button>
|
|
993
|
+
<button
|
|
994
|
+
onClick={() => {
|
|
995
|
+
setResetPasswordTarget({ id: user.id, username: user.username });
|
|
996
|
+
setResetPasswordValue("");
|
|
997
|
+
setConfirmResetPasswordValue("");
|
|
998
|
+
}}
|
|
999
|
+
disabled={!canDisable}
|
|
1000
|
+
className="focus-ring flex-1 inline-flex items-center justify-center gap-1.5 rounded-lg border border-slate-300 px-3 py-2 text-xs font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
1001
|
+
>
|
|
1002
|
+
<UserX className="w-3.5 h-3.5" />
|
|
1003
|
+
Password
|
|
1004
|
+
</button>
|
|
1005
|
+
<button
|
|
1006
|
+
onClick={() => handleToggleUserStatus(user.id, nextStatus)}
|
|
1007
|
+
disabled={!canDisable}
|
|
1008
|
+
className="focus-ring flex-1 inline-flex items-center justify-center gap-1.5 rounded-lg border border-slate-300 px-3 py-2 text-xs font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
1009
|
+
>
|
|
1010
|
+
<Shield className="w-3.5 h-3.5" />
|
|
1011
|
+
{statusLabel}
|
|
1012
|
+
</button>
|
|
1013
|
+
<button
|
|
1014
|
+
onClick={() => setDeleteUserTarget({ id: user.id, username: user.username })}
|
|
1015
|
+
disabled={!canDelete}
|
|
1016
|
+
className="focus-ring p-2 text-slate-400 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed border border-slate-300"
|
|
1017
|
+
title="Delete user"
|
|
1018
|
+
>
|
|
1019
|
+
<Trash2 className="w-4 h-4" />
|
|
1020
|
+
</button>
|
|
1021
|
+
</div>
|
|
1022
|
+
</div>
|
|
1023
|
+
</div>
|
|
1024
|
+
);
|
|
1025
|
+
})
|
|
1026
|
+
)}
|
|
1027
|
+
</div>
|
|
1028
|
+
</div>
|
|
1029
|
+
)}
|
|
1030
|
+
</div>
|
|
1031
|
+
)}
|
|
1032
|
+
</main>
|
|
1033
|
+
|
|
1034
|
+
<Modal
|
|
1035
|
+
isOpen={isContentModalOpen}
|
|
1036
|
+
onClose={() => setIsContentModalOpen(false)}
|
|
1037
|
+
title={isNewContent ? "New Content Element" : "Edit Content"}
|
|
1038
|
+
footer={
|
|
1039
|
+
<>
|
|
1040
|
+
<button onClick={() => setIsContentModalOpen(false)} className="focus-ring px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900">Cancel</button>
|
|
1041
|
+
<button
|
|
1042
|
+
onClick={handleSaveContent}
|
|
1043
|
+
disabled={!editingKey || !editingValue || isSavingContent}
|
|
1044
|
+
className="focus-ring bg-slate-900 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-slate-800 disabled:opacity-50"
|
|
1045
|
+
>
|
|
1046
|
+
{isSavingContent ? "Saving..." : "Save Content"}
|
|
1047
|
+
</button>
|
|
1048
|
+
</>
|
|
1049
|
+
}
|
|
1050
|
+
>
|
|
1051
|
+
<div className="space-y-4">
|
|
1052
|
+
<div>
|
|
1053
|
+
<label className="block text-xs font-semibold uppercase tracking-wider text-slate-500 mb-1">Key</label>
|
|
1054
|
+
<input
|
|
1055
|
+
type="text"
|
|
1056
|
+
value={editingKey}
|
|
1057
|
+
onChange={(e) => setEditingKey(e.target.value)}
|
|
1058
|
+
disabled={!isNewContent}
|
|
1059
|
+
className="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-lg focus-ring focus:border-transparent outline-none font-mono text-sm disabled:text-slate-400"
|
|
1060
|
+
placeholder="e.g. hero_title"
|
|
1061
|
+
maxLength={64}
|
|
1062
|
+
/>
|
|
1063
|
+
</div>
|
|
1064
|
+
<div>
|
|
1065
|
+
<label className="block text-xs font-semibold uppercase tracking-wider text-slate-500 mb-1">Value</label>
|
|
1066
|
+
<textarea
|
|
1067
|
+
value={editingValue}
|
|
1068
|
+
onChange={(e) => setEditingValue(e.target.value)}
|
|
1069
|
+
className="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-lg focus-ring focus:border-transparent outline-none min-h-[100px]"
|
|
1070
|
+
placeholder="Content text..."
|
|
1071
|
+
maxLength={4000}
|
|
1072
|
+
/>
|
|
1073
|
+
</div>
|
|
1074
|
+
</div>
|
|
1075
|
+
</Modal>
|
|
1076
|
+
|
|
1077
|
+
<Modal
|
|
1078
|
+
isOpen={!!deleteTarget}
|
|
1079
|
+
onClose={() => setDeleteTarget(null)}
|
|
1080
|
+
title="Confirm Deletion"
|
|
1081
|
+
footer={
|
|
1082
|
+
<>
|
|
1083
|
+
<button onClick={() => setDeleteTarget(null)} className="focus-ring px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900">Cancel</button>
|
|
1084
|
+
<button
|
|
1085
|
+
onClick={handleDelete}
|
|
1086
|
+
disabled={isDeleting}
|
|
1087
|
+
className="focus-ring bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-red-700 disabled:opacity-50"
|
|
1088
|
+
>
|
|
1089
|
+
{isDeleting ? "Deleting..." : "Delete Permanently"}
|
|
1090
|
+
</button>
|
|
1091
|
+
</>
|
|
1092
|
+
}
|
|
1093
|
+
>
|
|
1094
|
+
<div className="text-center py-4">
|
|
1095
|
+
<div className="bg-red-100 w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
1096
|
+
<Trash2 className="w-6 h-6 text-red-600" />
|
|
1097
|
+
</div>
|
|
1098
|
+
<div className="space-y-2 text-slate-600">
|
|
1099
|
+
<p className="break-words">
|
|
1100
|
+
Are you sure you want to delete this {deleteTarget?.type === "text" ? "content element" : "image"}?
|
|
1101
|
+
</p>
|
|
1102
|
+
<p className="text-sm">
|
|
1103
|
+
{deleteTarget?.type === "text"
|
|
1104
|
+
? "Any code referencing this key will return empty values."
|
|
1105
|
+
: "This image will no longer be accessible via its URL."
|
|
1106
|
+
}
|
|
1107
|
+
</p>
|
|
1108
|
+
<p className="text-sm font-medium text-red-600">This action cannot be undone.</p>
|
|
1109
|
+
</div>
|
|
1110
|
+
</div>
|
|
1111
|
+
</Modal>
|
|
1112
|
+
|
|
1113
|
+
<Modal
|
|
1114
|
+
isOpen={isUploadModalOpen}
|
|
1115
|
+
onClose={() => setIsUploadModalOpen(false)}
|
|
1116
|
+
title="Upload Image"
|
|
1117
|
+
footer={
|
|
1118
|
+
<>
|
|
1119
|
+
<button onClick={() => setIsUploadModalOpen(false)} className="focus-ring px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900">Cancel</button>
|
|
1120
|
+
<button
|
|
1121
|
+
onClick={handleUpload}
|
|
1122
|
+
disabled={!file || isUploading}
|
|
1123
|
+
className="focus-ring bg-teal-700 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-teal-800 disabled:opacity-50"
|
|
1124
|
+
>
|
|
1125
|
+
{isUploading ? "Uploading..." : "Upload"}
|
|
1126
|
+
</button>
|
|
1127
|
+
</>
|
|
1128
|
+
}
|
|
1129
|
+
>
|
|
1130
|
+
<div className="space-y-4">
|
|
1131
|
+
<div className="border-2 border-dashed border-slate-200 rounded-lg p-6 sm:p-8 text-center hover:bg-slate-50 transition-colors relative">
|
|
1132
|
+
<input
|
|
1133
|
+
type="file"
|
|
1134
|
+
accept="image/*"
|
|
1135
|
+
onChange={(e) => {
|
|
1136
|
+
setFile(e.target.files?.[0] || null);
|
|
1137
|
+
setUploadError(null);
|
|
1138
|
+
}}
|
|
1139
|
+
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
|
1140
|
+
/>
|
|
1141
|
+
<div className="flex flex-col items-center gap-2 pointer-events-none">
|
|
1142
|
+
<ImageIcon className="w-8 h-8 text-slate-400" />
|
|
1143
|
+
<span className="text-sm font-medium text-slate-600 break-all">
|
|
1144
|
+
{file ? file.name : "Click or drag an image file"}
|
|
1145
|
+
</span>
|
|
1146
|
+
<span className="text-xs text-slate-500">PNG, JPG, WEBP, SVG supported</span>
|
|
1147
|
+
</div>
|
|
1148
|
+
</div>
|
|
1149
|
+
<input
|
|
1150
|
+
type="text"
|
|
1151
|
+
value={altText}
|
|
1152
|
+
onChange={(e) => setAltText(e.target.value)}
|
|
1153
|
+
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus-ring text-sm"
|
|
1154
|
+
placeholder="Alt Text (Optional)"
|
|
1155
|
+
/>
|
|
1156
|
+
{uploadError && (
|
|
1157
|
+
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
1158
|
+
{uploadError}
|
|
1159
|
+
</div>
|
|
1160
|
+
)}
|
|
1161
|
+
</div>
|
|
1162
|
+
</Modal>
|
|
1163
|
+
|
|
1164
|
+
<Modal
|
|
1165
|
+
isOpen={isUserModalOpen}
|
|
1166
|
+
onClose={() => setIsUserModalOpen(false)}
|
|
1167
|
+
title="Create Admin User"
|
|
1168
|
+
footer={
|
|
1169
|
+
<>
|
|
1170
|
+
<button onClick={() => setIsUserModalOpen(false)} className="focus-ring px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900">Cancel</button>
|
|
1171
|
+
<button
|
|
1172
|
+
onClick={handleCreateUser}
|
|
1173
|
+
disabled={!newUsername || !newPassword || isCreatingUser}
|
|
1174
|
+
className="focus-ring bg-slate-900 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-slate-800 disabled:opacity-50"
|
|
1175
|
+
>
|
|
1176
|
+
{isCreatingUser ? "Creating..." : "Create User"}
|
|
1177
|
+
</button>
|
|
1178
|
+
</>
|
|
1179
|
+
}
|
|
1180
|
+
>
|
|
1181
|
+
<div className="space-y-4">
|
|
1182
|
+
<div>
|
|
1183
|
+
<label className="block text-xs font-semibold uppercase tracking-wider text-slate-500 mb-1">Username</label>
|
|
1184
|
+
<input
|
|
1185
|
+
type="text"
|
|
1186
|
+
value={newUsername}
|
|
1187
|
+
onChange={(e) => setNewUsername(e.target.value)}
|
|
1188
|
+
className="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-lg focus-ring focus:border-transparent outline-none"
|
|
1189
|
+
placeholder="e.g. sam_editor"
|
|
1190
|
+
/>
|
|
1191
|
+
</div>
|
|
1192
|
+
<div>
|
|
1193
|
+
<label className="block text-xs font-semibold uppercase tracking-wider text-slate-500 mb-1">Temporary Password</label>
|
|
1194
|
+
<input
|
|
1195
|
+
type="password"
|
|
1196
|
+
value={newPassword}
|
|
1197
|
+
onChange={(e) => setNewPassword(e.target.value)}
|
|
1198
|
+
className="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-lg focus-ring focus:border-transparent outline-none"
|
|
1199
|
+
placeholder="Set password"
|
|
1200
|
+
/>
|
|
1201
|
+
</div>
|
|
1202
|
+
<div>
|
|
1203
|
+
<label className="block text-xs font-semibold uppercase tracking-wider text-slate-500 mb-1">Role</label>
|
|
1204
|
+
<select
|
|
1205
|
+
value={newRole}
|
|
1206
|
+
onChange={(e) => setNewRole(e.target.value as "admin" | "editor" | "viewer")}
|
|
1207
|
+
className="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-lg focus-ring focus:border-transparent outline-none"
|
|
1208
|
+
>
|
|
1209
|
+
<option value="admin">Admin</option>
|
|
1210
|
+
<option value="editor">Editor</option>
|
|
1211
|
+
<option value="viewer">Viewer</option>
|
|
1212
|
+
</select>
|
|
1213
|
+
</div>
|
|
1214
|
+
{userError && (
|
|
1215
|
+
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
1216
|
+
{userError}
|
|
1217
|
+
</div>
|
|
1218
|
+
)}
|
|
1219
|
+
</div>
|
|
1220
|
+
</Modal>
|
|
1221
|
+
|
|
1222
|
+
<Modal
|
|
1223
|
+
isOpen={!!deleteUserTarget}
|
|
1224
|
+
onClose={() => setDeleteUserTarget(null)}
|
|
1225
|
+
title="Delete User"
|
|
1226
|
+
footer={
|
|
1227
|
+
<>
|
|
1228
|
+
<button onClick={() => setDeleteUserTarget(null)} className="focus-ring px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900">Cancel</button>
|
|
1229
|
+
<button
|
|
1230
|
+
onClick={handleDeleteUser}
|
|
1231
|
+
disabled={isDeletingUser}
|
|
1232
|
+
className="focus-ring bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-red-700 disabled:opacity-50"
|
|
1233
|
+
>
|
|
1234
|
+
{isDeletingUser ? "Deleting..." : "Delete User"}
|
|
1235
|
+
</button>
|
|
1236
|
+
</>
|
|
1237
|
+
}
|
|
1238
|
+
>
|
|
1239
|
+
<div className="space-y-3 text-sm text-slate-600">
|
|
1240
|
+
<p>
|
|
1241
|
+
Remove <span className="font-medium text-slate-800">{deleteUserTarget?.username}</span> from this admin workspace?
|
|
1242
|
+
</p>
|
|
1243
|
+
<p>This permanently deletes the account and revokes all active sessions.</p>
|
|
1244
|
+
</div>
|
|
1245
|
+
</Modal>
|
|
1246
|
+
|
|
1247
|
+
<Modal
|
|
1248
|
+
isOpen={!!resetPasswordTarget}
|
|
1249
|
+
onClose={() => {
|
|
1250
|
+
setResetPasswordTarget(null);
|
|
1251
|
+
setResetPasswordValue("");
|
|
1252
|
+
setConfirmResetPasswordValue("");
|
|
1253
|
+
}}
|
|
1254
|
+
title="Reset User Password"
|
|
1255
|
+
footer={
|
|
1256
|
+
<>
|
|
1257
|
+
<button
|
|
1258
|
+
onClick={() => {
|
|
1259
|
+
setResetPasswordTarget(null);
|
|
1260
|
+
setResetPasswordValue("");
|
|
1261
|
+
setConfirmResetPasswordValue("");
|
|
1262
|
+
}}
|
|
1263
|
+
className="focus-ring px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900"
|
|
1264
|
+
>
|
|
1265
|
+
Cancel
|
|
1266
|
+
</button>
|
|
1267
|
+
<button
|
|
1268
|
+
onClick={handleResetUserPassword}
|
|
1269
|
+
disabled={isResettingUserPassword}
|
|
1270
|
+
className="focus-ring bg-slate-900 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-slate-800 disabled:opacity-50"
|
|
1271
|
+
>
|
|
1272
|
+
{isResettingUserPassword ? "Resetting..." : "Reset Password"}
|
|
1273
|
+
</button>
|
|
1274
|
+
</>
|
|
1275
|
+
}
|
|
1276
|
+
>
|
|
1277
|
+
<div className="space-y-4">
|
|
1278
|
+
<p className="text-sm text-slate-600">
|
|
1279
|
+
Set a new password for <span className="font-medium text-slate-800">{resetPasswordTarget?.username}</span>. This will revoke active sessions.
|
|
1280
|
+
</p>
|
|
1281
|
+
<div>
|
|
1282
|
+
<label className="block text-xs font-semibold uppercase tracking-wider text-slate-500 mb-1">New Password</label>
|
|
1283
|
+
<input
|
|
1284
|
+
type="password"
|
|
1285
|
+
value={resetPasswordValue}
|
|
1286
|
+
onChange={(e) => setResetPasswordValue(e.target.value)}
|
|
1287
|
+
className="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-lg focus-ring focus:border-transparent outline-none"
|
|
1288
|
+
placeholder="Enter new password"
|
|
1289
|
+
/>
|
|
1290
|
+
</div>
|
|
1291
|
+
<div>
|
|
1292
|
+
<label className="block text-xs font-semibold uppercase tracking-wider text-slate-500 mb-1">Confirm New Password</label>
|
|
1293
|
+
<input
|
|
1294
|
+
type="password"
|
|
1295
|
+
value={confirmResetPasswordValue}
|
|
1296
|
+
onChange={(e) => setConfirmResetPasswordValue(e.target.value)}
|
|
1297
|
+
className="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-lg focus-ring focus:border-transparent outline-none"
|
|
1298
|
+
placeholder="Confirm new password"
|
|
1299
|
+
/>
|
|
1300
|
+
</div>
|
|
1301
|
+
<p className="text-xs text-slate-500">Password must be 10-128 chars and include uppercase, lowercase, and a number.</p>
|
|
1302
|
+
<p className="text-xs text-slate-500">If this user is locked or disabled, reset will reactivate the account for secure recovery.</p>
|
|
1303
|
+
</div>
|
|
1304
|
+
</Modal>
|
|
1305
|
+
|
|
1306
|
+
<Modal
|
|
1307
|
+
isOpen={!!resetTokenResult}
|
|
1308
|
+
onClose={() => setResetTokenResult(null)}
|
|
1309
|
+
title="One-Time Password Reset Token"
|
|
1310
|
+
footer={
|
|
1311
|
+
<>
|
|
1312
|
+
<button
|
|
1313
|
+
ref={resetTokenCloseRef}
|
|
1314
|
+
onClick={() => setResetTokenResult(null)}
|
|
1315
|
+
className="focus-ring px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900"
|
|
1316
|
+
>
|
|
1317
|
+
Close
|
|
1318
|
+
</button>
|
|
1319
|
+
<button
|
|
1320
|
+
onClick={() => {
|
|
1321
|
+
if (resetTokenResult) {
|
|
1322
|
+
navigator.clipboard.writeText(resetTokenResult.token);
|
|
1323
|
+
resetTokenCloseRef.current?.focus();
|
|
1324
|
+
}
|
|
1325
|
+
}}
|
|
1326
|
+
className="focus-ring bg-slate-900 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-slate-800"
|
|
1327
|
+
>
|
|
1328
|
+
Copy Token
|
|
1329
|
+
</button>
|
|
1330
|
+
</>
|
|
1331
|
+
}
|
|
1332
|
+
>
|
|
1333
|
+
<div className="space-y-3 text-sm text-slate-600" aria-live="polite">
|
|
1334
|
+
<p>
|
|
1335
|
+
Share this one-time token with <span className="font-medium text-slate-800">{resetTokenResult?.username}</span> to reset their password.
|
|
1336
|
+
</p>
|
|
1337
|
+
<p className="text-xs text-slate-500">Expires at {resetTokenResult ? new Date(resetTokenResult.expiresAt).toLocaleString() : "-"}.</p>
|
|
1338
|
+
<div className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 font-mono text-xs break-all text-slate-700">
|
|
1339
|
+
{resetTokenResult?.token}
|
|
1340
|
+
</div>
|
|
1341
|
+
<p className="text-xs text-amber-700">Token is shown once. Creating a new token invalidates prior tokens for this user.</p>
|
|
1342
|
+
</div>
|
|
1343
|
+
</Modal>
|
|
1344
|
+
</div>
|
|
1345
|
+
);
|
|
1346
|
+
}
|