@actuate-media/cms-admin 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/LICENSE +21 -21
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +16 -10
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/actuate-admin.css +2 -0
  6. package/dist/components/TipTapEditor.js +78 -78
  7. package/dist/lib/useApiData.d.ts +8 -1
  8. package/dist/lib/useApiData.d.ts.map +1 -1
  9. package/dist/lib/useApiData.js +39 -7
  10. package/dist/lib/useApiData.js.map +1 -1
  11. package/dist/views/Dashboard.d.ts.map +1 -1
  12. package/dist/views/Dashboard.js +8 -3
  13. package/dist/views/Dashboard.js.map +1 -1
  14. package/package.json +10 -5
  15. package/src/AdminRoot.tsx +312 -0
  16. package/src/__tests__/lib/search.test.ts +138 -0
  17. package/src/__tests__/lib/utils.test.ts +19 -0
  18. package/src/__tests__/router/match-route.test.ts +47 -0
  19. package/src/__tests__/router/strip-base.test.ts +30 -0
  20. package/src/components/Breadcrumbs.tsx +92 -0
  21. package/src/components/CommandPalette.tsx +384 -0
  22. package/src/components/ErrorBoundary.tsx +52 -0
  23. package/src/components/FocalPointPicker.tsx +54 -0
  24. package/src/components/FolderTree.tsx +427 -0
  25. package/src/components/LivePreview.tsx +136 -0
  26. package/src/components/LocaleProvider.tsx +51 -0
  27. package/src/components/LocaleSwitcher.tsx +51 -0
  28. package/src/components/MediaPickerModal.tsx +183 -0
  29. package/src/components/PresenceIndicator.tsx +71 -0
  30. package/src/components/SEOPanel.tsx +767 -0
  31. package/src/components/ThemeProvider.tsx +98 -0
  32. package/src/components/TipTapEditor.tsx +469 -0
  33. package/src/components/VersionHistory.tsx +167 -0
  34. package/src/components/ui/Avatar.tsx +42 -0
  35. package/src/components/ui/Badge.tsx +25 -0
  36. package/src/components/ui/Button.tsx +52 -0
  37. package/src/components/ui/CommandPalette.tsx +119 -0
  38. package/src/components/ui/ConfirmDialog.tsx +52 -0
  39. package/src/components/ui/DataTable.tsx +194 -0
  40. package/src/components/ui/EmptyState.tsx +29 -0
  41. package/src/components/ui/Modal.tsx +48 -0
  42. package/src/components/ui/Pagination.tsx +79 -0
  43. package/src/components/ui/SearchInput.tsx +44 -0
  44. package/src/components/ui/Skeleton.tsx +48 -0
  45. package/src/components/ui/Toast.tsx +66 -0
  46. package/src/components/ui/index.ts +24 -0
  47. package/src/fields/ArrayField.tsx +92 -0
  48. package/src/fields/BlockBuilderField.tsx +421 -0
  49. package/src/fields/DateField.tsx +41 -0
  50. package/src/fields/FieldRenderer.tsx +84 -0
  51. package/src/fields/GroupField.tsx +41 -0
  52. package/src/fields/MediaField.tsx +48 -0
  53. package/src/fields/NavBuilderField.tsx +78 -0
  54. package/src/fields/NumberField.tsx +45 -0
  55. package/src/fields/RelationshipField.tsx +245 -0
  56. package/src/fields/RichTextField.tsx +26 -0
  57. package/src/fields/SelectField.tsx +117 -0
  58. package/src/fields/SlugField.tsx +65 -0
  59. package/src/fields/TextField.tsx +48 -0
  60. package/src/fields/ToggleField.tsx +36 -0
  61. package/src/fields/block-types.ts +95 -0
  62. package/src/fields/index.ts +17 -0
  63. package/src/hooks/useContentLock.ts +52 -0
  64. package/src/hooks/useDebounce.ts +14 -0
  65. package/src/hooks/useKeyboardShortcuts.ts +32 -0
  66. package/src/index.ts +55 -0
  67. package/src/layout/Header.tsx +135 -0
  68. package/src/layout/Layout.tsx +77 -0
  69. package/src/layout/Sidebar.tsx +216 -0
  70. package/src/lib/api.ts +67 -0
  71. package/src/lib/search.ts +59 -0
  72. package/src/lib/useApiData.ts +95 -0
  73. package/src/lib/utils.ts +6 -0
  74. package/src/router/index.ts +81 -0
  75. package/src/styles/build-input.css +11 -0
  76. package/src/styles/tailwind.css +11 -6
  77. package/src/styles/theme.css +182 -181
  78. package/src/views/CollectionList.tsx +270 -0
  79. package/src/views/Dashboard.tsx +207 -0
  80. package/src/views/DocumentEdit.tsx +377 -0
  81. package/src/views/FormEditor.tsx +533 -0
  82. package/src/views/FormSubmissions.tsx +316 -0
  83. package/src/views/Forms.tsx +106 -0
  84. package/src/views/Login.tsx +322 -0
  85. package/src/views/MediaBrowser.tsx +774 -0
  86. package/src/views/PageEditor.tsx +192 -0
  87. package/src/views/Pages.tsx +354 -0
  88. package/src/views/PostEditor.tsx +251 -0
  89. package/src/views/Posts.tsx +243 -0
  90. package/src/views/Redirects.tsx +293 -0
  91. package/src/views/SEO.tsx +458 -0
  92. package/src/views/Settings.tsx +811 -0
  93. package/src/views/SetupWizard.tsx +207 -0
  94. package/src/views/Users.tsx +282 -0
@@ -0,0 +1,207 @@
1
+ 'use client';
2
+
3
+ import { useState, type FormEvent } from 'react';
4
+ import { Shield, Eye, EyeOff, CheckCircle2, AlertTriangle } from 'lucide-react';
5
+
6
+ export interface SetupWizardProps {
7
+ onComplete: (data: { name: string; email: string; password: string }) => Promise<{ success: boolean; error?: string }>;
8
+ siteName?: string;
9
+ }
10
+
11
+ function passwordStrength(pw: string): { score: number; label: string; color: string } {
12
+ let score = 0;
13
+ if (pw.length >= 12) score++;
14
+ if (pw.length >= 16) score++;
15
+ if (/[A-Z]/.test(pw)) score++;
16
+ if (/[a-z]/.test(pw)) score++;
17
+ if (/\d/.test(pw)) score++;
18
+ if (/[^a-zA-Z0-9]/.test(pw)) score++;
19
+
20
+ if (score <= 2) return { score, label: 'Weak', color: 'bg-red-500' };
21
+ if (score <= 4) return { score, label: 'Fair', color: 'bg-yellow-500' };
22
+ return { score, label: 'Strong', color: 'bg-green-500' };
23
+ }
24
+
25
+ export function SetupWizard({ onComplete, siteName = 'Actuate CMS' }: SetupWizardProps) {
26
+ const [name, setName] = useState('');
27
+ const [email, setEmail] = useState('');
28
+ const [password, setPassword] = useState('');
29
+ const [confirmPassword, setConfirmPassword] = useState('');
30
+ const [showPassword, setShowPassword] = useState(false);
31
+ const [submitting, setSubmitting] = useState(false);
32
+ const [error, setError] = useState('');
33
+ const [done, setDone] = useState(false);
34
+
35
+ const strength = passwordStrength(password);
36
+ const passwordsMatch = password === confirmPassword;
37
+ const canSubmit = name.trim() && email.trim() && password.length >= 12 && passwordsMatch && !submitting;
38
+
39
+ const handleSubmit = async (e: FormEvent) => {
40
+ e.preventDefault();
41
+ if (!canSubmit) return;
42
+
43
+ setError('');
44
+ setSubmitting(true);
45
+
46
+ try {
47
+ const result = await onComplete({
48
+ name: name.trim(),
49
+ email: email.trim(),
50
+ password,
51
+ });
52
+
53
+ if (result.success) {
54
+ setDone(true);
55
+ } else {
56
+ setError(result.error ?? 'Failed to create admin account');
57
+ }
58
+ } catch (err) {
59
+ setError(err instanceof Error ? err.message : 'An unexpected error occurred');
60
+ } finally {
61
+ setSubmitting(false);
62
+ }
63
+ };
64
+
65
+ if (done) {
66
+ return (
67
+ <div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
68
+ <div className="w-full max-w-md text-center">
69
+ <div className="mx-auto mb-6 w-16 h-16 bg-green-100 rounded-full flex items-center justify-center">
70
+ <CheckCircle2 className="w-8 h-8 text-green-600" />
71
+ </div>
72
+ <h1 className="text-2xl font-bold text-gray-900 mb-2">Setup Complete</h1>
73
+ <p className="text-gray-600 mb-6">
74
+ Your admin account has been created. You can now sign in to manage your content.
75
+ </p>
76
+ <button
77
+ onClick={() => window.location.reload()}
78
+ className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
79
+ >
80
+ Go to Login
81
+ </button>
82
+ </div>
83
+ </div>
84
+ );
85
+ }
86
+
87
+ return (
88
+ <div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
89
+ <div className="w-full max-w-md">
90
+ <div className="text-center mb-8">
91
+ <div className="mx-auto mb-4 w-14 h-14 bg-blue-600 rounded-xl flex items-center justify-center">
92
+ <Shield className="w-7 h-7 text-white" />
93
+ </div>
94
+ <h1 className="text-2xl font-bold text-gray-900">Welcome to {siteName}</h1>
95
+ <p className="text-gray-600 mt-2">Create your admin account to get started</p>
96
+ </div>
97
+
98
+ <form onSubmit={handleSubmit} className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm space-y-5">
99
+ {error && (
100
+ <div className="flex items-start gap-3 p-3 bg-red-50 border border-red-200 rounded-lg">
101
+ <AlertTriangle className="w-5 h-5 text-red-600 mt-0.5 shrink-0" />
102
+ <p className="text-sm text-red-800">{error}</p>
103
+ </div>
104
+ )}
105
+
106
+ <div>
107
+ <label htmlFor="setup-name" className="block text-sm font-medium text-gray-700 mb-1.5">Full Name</label>
108
+ <input
109
+ id="setup-name"
110
+ type="text"
111
+ value={name}
112
+ onChange={(e) => setName(e.target.value)}
113
+ placeholder="Jane Smith"
114
+ className="w-full px-3 py-2.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
115
+ required
116
+ autoFocus
117
+ autoComplete="name"
118
+ />
119
+ </div>
120
+
121
+ <div>
122
+ <label htmlFor="setup-email" className="block text-sm font-medium text-gray-700 mb-1.5">Email Address</label>
123
+ <input
124
+ id="setup-email"
125
+ type="email"
126
+ value={email}
127
+ onChange={(e) => setEmail(e.target.value)}
128
+ placeholder="admin@example.com"
129
+ className="w-full px-3 py-2.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
130
+ required
131
+ autoComplete="email"
132
+ />
133
+ </div>
134
+
135
+ <div>
136
+ <label htmlFor="setup-password" className="block text-sm font-medium text-gray-700 mb-1.5">Password</label>
137
+ <div className="relative">
138
+ <input
139
+ id="setup-password"
140
+ type={showPassword ? 'text' : 'password'}
141
+ value={password}
142
+ onChange={(e) => setPassword(e.target.value)}
143
+ placeholder="Minimum 12 characters"
144
+ className="w-full px-3 py-2.5 pr-10 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
145
+ required
146
+ minLength={12}
147
+ autoComplete="new-password"
148
+ />
149
+ <button
150
+ type="button"
151
+ onClick={() => setShowPassword(!showPassword)}
152
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
153
+ tabIndex={-1}
154
+ >
155
+ {showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
156
+ </button>
157
+ </div>
158
+ {password && (
159
+ <div className="mt-2">
160
+ <div className="flex items-center gap-2">
161
+ <div className="flex-1 h-1.5 bg-gray-200 rounded-full overflow-hidden">
162
+ <div
163
+ className={`h-full rounded-full transition-all ${strength.color}`}
164
+ style={{ width: `${(strength.score / 6) * 100}%` }}
165
+ />
166
+ </div>
167
+ <span className="text-xs text-gray-600">{strength.label}</span>
168
+ </div>
169
+ </div>
170
+ )}
171
+ </div>
172
+
173
+ <div>
174
+ <label htmlFor="setup-confirm" className="block text-sm font-medium text-gray-700 mb-1.5">Confirm Password</label>
175
+ <input
176
+ id="setup-confirm"
177
+ type={showPassword ? 'text' : 'password'}
178
+ value={confirmPassword}
179
+ onChange={(e) => setConfirmPassword(e.target.value)}
180
+ placeholder="Re-enter your password"
181
+ className={`w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
182
+ confirmPassword && !passwordsMatch ? 'border-red-300' : 'border-gray-300'
183
+ }`}
184
+ required
185
+ autoComplete="new-password"
186
+ />
187
+ {confirmPassword && !passwordsMatch && (
188
+ <p className="text-xs text-red-600 mt-1">Passwords do not match</p>
189
+ )}
190
+ </div>
191
+
192
+ <button
193
+ type="submit"
194
+ disabled={!canSubmit}
195
+ className="w-full py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
196
+ >
197
+ {submitting ? 'Creating Account...' : 'Create Admin Account'}
198
+ </button>
199
+
200
+ <p className="text-xs text-gray-500 text-center">
201
+ This form is only available during initial setup. Once an admin account exists, it will be replaced by the login screen.
202
+ </p>
203
+ </form>
204
+ </div>
205
+ </div>
206
+ );
207
+ }
@@ -0,0 +1,282 @@
1
+ 'use client';
2
+
3
+ import * as Dialog from '@radix-ui/react-dialog';
4
+ import { Pencil, Trash2, UserPlus, Search, ArrowUpDown, ArrowUp, ArrowDown, Loader2, AlertTriangle } from 'lucide-react';
5
+ import { useState, useMemo, type FormEvent } from 'react';
6
+ import { toast } from 'sonner';
7
+ import { sortByRelevance, type SortConfig, toggleSort } from '../lib/search.js';
8
+ import { useApiData } from '../lib/useApiData.js';
9
+ import { cmsApi } from '../lib/api.js';
10
+
11
+ type UserSortKey = 'name' | 'role' | 'status' | 'lastLogin';
12
+
13
+ export interface UsersProps {
14
+ onNavigate?: (path: string) => void;
15
+ }
16
+
17
+ export function Users({ onNavigate }: UsersProps) {
18
+ const { data, loading, error, refetch } = useApiData<any[]>('/users');
19
+ const [showInviteDialog, setShowInviteDialog] = useState(false);
20
+ const [inviteEmail, setInviteEmail] = useState('');
21
+ const [inviteRole, setInviteRole] = useState('editor');
22
+ const [searchQuery, setSearchQuery] = useState('');
23
+ const [filterRole, setFilterRole] = useState('all');
24
+ const [sortConfig, setSortConfig] = useState<SortConfig<UserSortKey> | null>(null);
25
+
26
+ const users = data ?? [];
27
+
28
+ const filteredAndSorted = useMemo(() => {
29
+ let results = users.filter((user: any) => {
30
+ const matchesSearch =
31
+ user.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
32
+ user.email.toLowerCase().includes(searchQuery.toLowerCase());
33
+ const matchesRole = filterRole === 'all' || user.role.toLowerCase() === filterRole.toLowerCase();
34
+ return matchesSearch && matchesRole;
35
+ });
36
+
37
+ if (searchQuery.trim()) {
38
+ results = sortByRelevance(results, searchQuery, (u: any) => [u.name, u.email, u.role]);
39
+ } else if (sortConfig) {
40
+ results = [...results].sort((a: any, b: any) => {
41
+ const aVal = a[sortConfig.key] ?? '';
42
+ const bVal = b[sortConfig.key] ?? '';
43
+ const cmp = String(aVal).localeCompare(String(bVal));
44
+ return sortConfig.direction === 'asc' ? cmp : -cmp;
45
+ });
46
+ }
47
+ return results;
48
+ }, [users, searchQuery, filterRole, sortConfig]);
49
+
50
+ const handleInvite = (e: FormEvent) => {
51
+ e.preventDefault();
52
+ toast.success(`Invitation sent to ${inviteEmail}`);
53
+ setShowInviteDialog(false);
54
+ setInviteEmail('');
55
+ setInviteRole('editor');
56
+ };
57
+
58
+ const handleDelete = async (userId: number) => {
59
+ const res = await cmsApi(`/users/${userId}`, { method: 'DELETE' });
60
+ if (res.error) {
61
+ toast.error(res.error);
62
+ } else {
63
+ toast.success('User removed');
64
+ refetch();
65
+ }
66
+ };
67
+
68
+ function SortHeader({ label, sortKey }: { label: string; sortKey: UserSortKey }) {
69
+ const active = sortConfig?.key === sortKey;
70
+ return (
71
+ <button
72
+ type="button"
73
+ onClick={() => setSortConfig(toggleSort(sortConfig, sortKey))}
74
+ className="flex items-center gap-1 text-xs font-medium text-gray-700 hover:text-gray-900 transition-colors"
75
+ >
76
+ {label}
77
+ {active ? (
78
+ sortConfig!.direction === 'asc' ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />
79
+ ) : (
80
+ <ArrowUpDown className="w-3 h-3 text-gray-400" />
81
+ )}
82
+ </button>
83
+ );
84
+ }
85
+
86
+ if (loading) {
87
+ return (
88
+ <div className="p-3 pr-6 sm:p-4 sm:pr-8 flex items-center justify-center h-64">
89
+ <Loader2 className="w-6 h-6 animate-spin text-blue-600" />
90
+ </div>
91
+ );
92
+ }
93
+
94
+ return (
95
+ <div className="p-3 pr-6 sm:p-4 sm:pr-8">
96
+ {error && (
97
+ <div className="mb-4 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
98
+ <AlertTriangle className="w-5 h-5 text-red-600 shrink-0" />
99
+ <span className="text-sm text-red-800 flex-1">{error}</span>
100
+ <button onClick={refetch} className="px-3 py-1 text-sm text-red-700 border border-red-300 rounded-lg hover:bg-red-100 transition-colors">Retry</button>
101
+ </div>
102
+ )}
103
+
104
+ <div className="mb-4 flex items-center justify-between">
105
+ <div>
106
+ <h1 className="mb-1 text-2xl font-semibold text-gray-900">Users</h1>
107
+ <p className="text-sm text-gray-600">{filteredAndSorted.length} total users</p>
108
+ </div>
109
+ <button
110
+ type="button"
111
+ onClick={() => setShowInviteDialog(true)}
112
+ className="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700"
113
+ >
114
+ <UserPlus className="h-4 w-4" />
115
+ Invite User
116
+ </button>
117
+ </div>
118
+
119
+ <div className="bg-white rounded-lg border border-gray-200 mb-4">
120
+ <div className="p-3 flex flex-col sm:flex-row gap-3">
121
+ <div className="relative flex-1">
122
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
123
+ <input
124
+ type="text"
125
+ placeholder="Search users by name or email..."
126
+ value={searchQuery}
127
+ onChange={(e) => setSearchQuery(e.target.value)}
128
+ className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
129
+ />
130
+ </div>
131
+ <select
132
+ value={filterRole}
133
+ onChange={(e) => setFilterRole(e.target.value)}
134
+ className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
135
+ >
136
+ <option value="all">All Roles</option>
137
+ <option value="admin">Admin</option>
138
+ <option value="editor">Editor</option>
139
+ <option value="author">Author</option>
140
+ <option value="client">Client</option>
141
+ </select>
142
+ </div>
143
+ </div>
144
+
145
+ {filteredAndSorted.length === 0 && !loading ? (
146
+ <div className="flex flex-col items-center justify-center py-16 text-center">
147
+ <div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center mb-4">
148
+ <UserPlus className="w-6 h-6 text-gray-400" />
149
+ </div>
150
+ <h3 className="text-sm font-medium text-gray-900 mb-1">No users yet</h3>
151
+ <p className="text-sm text-gray-500">Invite your first user to get started.</p>
152
+ </div>
153
+ ) : (
154
+ <div className="rounded-lg border border-gray-200 bg-white">
155
+ <div className="overflow-x-auto">
156
+ <table className="w-full">
157
+ <thead className="border-b border-gray-200 bg-gray-50">
158
+ <tr>
159
+ <th className="px-4 py-2 text-left"><SortHeader label="User" sortKey="name" /></th>
160
+ <th className="px-4 py-2 text-left"><SortHeader label="Role" sortKey="role" /></th>
161
+ <th className="px-4 py-2 text-left"><SortHeader label="Status" sortKey="status" /></th>
162
+ <th className="px-4 py-2 text-left"><SortHeader label="Last Active" sortKey="lastLogin" /></th>
163
+ <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Actions</th>
164
+ </tr>
165
+ </thead>
166
+ <tbody className="divide-y divide-gray-200">
167
+ {filteredAndSorted.map((user: any) => (
168
+ <tr key={user.id} className="transition-colors hover:bg-gray-50">
169
+ <td className="px-4 py-3">
170
+ <div className="flex items-center gap-3">
171
+ <div className="flex h-10 w-10 items-center justify-center rounded-full bg-linear-to-br from-blue-500 to-purple-600 text-sm font-medium text-white">
172
+ {user.name.charAt(0)}
173
+ </div>
174
+ <div>
175
+ <div className="text-sm font-medium text-gray-900">{user.name}</div>
176
+ <div className="text-xs text-gray-600">{user.email}</div>
177
+ </div>
178
+ </div>
179
+ </td>
180
+ <td className="px-4 py-3">
181
+ <span
182
+ className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
183
+ user.role === 'Admin'
184
+ ? 'bg-purple-100 text-purple-800'
185
+ : user.role === 'Editor'
186
+ ? 'bg-blue-100 text-blue-800'
187
+ : 'bg-gray-100 text-gray-800'
188
+ }`}
189
+ >
190
+ {user.role}
191
+ </span>
192
+ </td>
193
+ <td className="px-4 py-3">
194
+ <span
195
+ className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
196
+ user.status === 'Active' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
197
+ }`}
198
+ >
199
+ {user.status}
200
+ </span>
201
+ </td>
202
+ <td className="px-4 py-3 text-sm text-gray-600">{user.lastLogin}</td>
203
+ <td className="px-4 py-3">
204
+ <div className="flex items-center gap-2">
205
+ <button
206
+ type="button"
207
+ onClick={() => onNavigate?.(`/users/${user.id}`)}
208
+ className="rounded p-1.5 transition-colors hover:bg-gray-100"
209
+ aria-label={`Edit ${user.name}`}
210
+ >
211
+ <Pencil className="h-4 w-4 text-gray-600" />
212
+ </button>
213
+ <button
214
+ type="button"
215
+ onClick={() => handleDelete(user.id)}
216
+ className="rounded p-1.5 transition-colors hover:bg-gray-100"
217
+ aria-label={`Remove ${user.name}`}
218
+ >
219
+ <Trash2 className="h-4 w-4 text-red-600" />
220
+ </button>
221
+ </div>
222
+ </td>
223
+ </tr>
224
+ ))}
225
+ </tbody>
226
+ </table>
227
+ </div>
228
+ </div>
229
+ )}
230
+
231
+ <Dialog.Root open={showInviteDialog} onOpenChange={setShowInviteDialog}>
232
+ <Dialog.Portal>
233
+ <Dialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
234
+ <Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg bg-white p-6 shadow-lg">
235
+ <Dialog.Title className="mb-4 text-lg font-semibold text-gray-900">Invite User</Dialog.Title>
236
+ <form onSubmit={handleInvite} className="space-y-4">
237
+ <div>
238
+ <label className="mb-1 block text-sm font-medium text-gray-700">Email Address</label>
239
+ <input
240
+ type="email"
241
+ value={inviteEmail}
242
+ onChange={(e) => setInviteEmail(e.target.value)}
243
+ placeholder="user@example.com"
244
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
245
+ required
246
+ />
247
+ </div>
248
+ <div>
249
+ <label className="mb-1 block text-sm font-medium text-gray-700">Role</label>
250
+ <select
251
+ value={inviteRole}
252
+ onChange={(e) => setInviteRole(e.target.value)}
253
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
254
+ >
255
+ <option value="viewer">Viewer</option>
256
+ <option value="editor">Editor</option>
257
+ <option value="admin">Admin</option>
258
+ </select>
259
+ </div>
260
+ <div className="flex items-center justify-end gap-3 pt-4">
261
+ <Dialog.Close asChild>
262
+ <button
263
+ type="button"
264
+ className="rounded-lg border border-gray-300 px-4 py-2 text-sm transition-colors hover:bg-gray-50"
265
+ >
266
+ Cancel
267
+ </button>
268
+ </Dialog.Close>
269
+ <button
270
+ type="submit"
271
+ className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700"
272
+ >
273
+ Send Invite
274
+ </button>
275
+ </div>
276
+ </form>
277
+ </Dialog.Content>
278
+ </Dialog.Portal>
279
+ </Dialog.Root>
280
+ </div>
281
+ );
282
+ }