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.
@@ -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
+ }