@crmy/web 0.5.1 → 0.5.6

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 (121) hide show
  1. package/dist/assets/index-CskfWp8E.js +560 -0
  2. package/dist/assets/index-D763l57m.css +1 -0
  3. package/{index.html → dist/index.html} +2 -1
  4. package/package.json +4 -1
  5. package/postcss.config.js +0 -6
  6. package/src/App.tsx +0 -158
  7. package/src/api/client.ts +0 -82
  8. package/src/api/hooks.ts +0 -689
  9. package/src/components/CustomFields.tsx +0 -240
  10. package/src/components/NavLink.tsx +0 -28
  11. package/src/components/crm/AIFab.tsx +0 -37
  12. package/src/components/crm/AccountDrawer.tsx +0 -372
  13. package/src/components/crm/ActivityTimeline.tsx +0 -115
  14. package/src/components/crm/AssignmentDrawer.tsx +0 -396
  15. package/src/components/crm/BriefingPanel.tsx +0 -217
  16. package/src/components/crm/CommandPalette.tsx +0 -254
  17. package/src/components/crm/ContactAvatar.tsx +0 -49
  18. package/src/components/crm/ContactDrawer.tsx +0 -438
  19. package/src/components/crm/ContextPanel.tsx +0 -200
  20. package/src/components/crm/CrmWidgets.tsx +0 -417
  21. package/src/components/crm/DrawerShell.tsx +0 -77
  22. package/src/components/crm/ListToolbar.tsx +0 -252
  23. package/src/components/crm/OpportunityDrawer.tsx +0 -372
  24. package/src/components/crm/PaginationBar.tsx +0 -111
  25. package/src/components/crm/QuickAddDrawer.tsx +0 -652
  26. package/src/components/crm/ShortcutsOverlay.tsx +0 -65
  27. package/src/components/crm/UseCaseDrawer.tsx +0 -454
  28. package/src/components/layout/MobileNav.tsx +0 -49
  29. package/src/components/layout/Sidebar.tsx +0 -157
  30. package/src/components/layout/TopBar.tsx +0 -54
  31. package/src/components/settings/ActorsSettings.tsx +0 -1190
  32. package/src/components/ui/accordion.tsx +0 -52
  33. package/src/components/ui/alert-dialog.tsx +0 -104
  34. package/src/components/ui/alert.tsx +0 -43
  35. package/src/components/ui/aspect-ratio.tsx +0 -5
  36. package/src/components/ui/avatar.tsx +0 -38
  37. package/src/components/ui/badge.tsx +0 -29
  38. package/src/components/ui/breadcrumb.tsx +0 -90
  39. package/src/components/ui/button.tsx +0 -47
  40. package/src/components/ui/calendar.tsx +0 -54
  41. package/src/components/ui/card.tsx +0 -43
  42. package/src/components/ui/carousel.tsx +0 -224
  43. package/src/components/ui/chart.tsx +0 -303
  44. package/src/components/ui/checkbox.tsx +0 -26
  45. package/src/components/ui/collapsible.tsx +0 -9
  46. package/src/components/ui/command.tsx +0 -132
  47. package/src/components/ui/context-menu.tsx +0 -178
  48. package/src/components/ui/date-picker.tsx +0 -313
  49. package/src/components/ui/dialog.tsx +0 -95
  50. package/src/components/ui/drawer.tsx +0 -87
  51. package/src/components/ui/dropdown-menu.tsx +0 -179
  52. package/src/components/ui/form.tsx +0 -129
  53. package/src/components/ui/hover-card.tsx +0 -27
  54. package/src/components/ui/input-otp.tsx +0 -61
  55. package/src/components/ui/input.tsx +0 -22
  56. package/src/components/ui/label.tsx +0 -17
  57. package/src/components/ui/menubar.tsx +0 -207
  58. package/src/components/ui/navigation-menu.tsx +0 -120
  59. package/src/components/ui/pagination.tsx +0 -81
  60. package/src/components/ui/popover.tsx +0 -29
  61. package/src/components/ui/progress.tsx +0 -23
  62. package/src/components/ui/radio-group.tsx +0 -36
  63. package/src/components/ui/resizable.tsx +0 -37
  64. package/src/components/ui/scroll-area.tsx +0 -38
  65. package/src/components/ui/select.tsx +0 -143
  66. package/src/components/ui/separator.tsx +0 -20
  67. package/src/components/ui/sheet.tsx +0 -107
  68. package/src/components/ui/sidebar.tsx +0 -637
  69. package/src/components/ui/skeleton.tsx +0 -7
  70. package/src/components/ui/slider.tsx +0 -23
  71. package/src/components/ui/sonner.tsx +0 -24
  72. package/src/components/ui/switch.tsx +0 -27
  73. package/src/components/ui/table.tsx +0 -72
  74. package/src/components/ui/tabs.tsx +0 -53
  75. package/src/components/ui/textarea.tsx +0 -21
  76. package/src/components/ui/toast.tsx +0 -111
  77. package/src/components/ui/toaster.tsx +0 -24
  78. package/src/components/ui/toggle-group.tsx +0 -49
  79. package/src/components/ui/toggle.tsx +0 -37
  80. package/src/components/ui/tooltip.tsx +0 -28
  81. package/src/components/ui/use-toast.ts +0 -1
  82. package/src/components/ui/utils.ts +0 -9
  83. package/src/contexts/AgentSettingsContext.tsx +0 -24
  84. package/src/hooks/use-mobile.tsx +0 -19
  85. package/src/hooks/use-toast.ts +0 -186
  86. package/src/hooks/useKeyboardShortcuts.ts +0 -95
  87. package/src/hooks/useTheme.ts +0 -24
  88. package/src/index.css +0 -245
  89. package/src/lib/entityColors.ts +0 -18
  90. package/src/lib/stageConfig.ts +0 -32
  91. package/src/lib/utils.ts +0 -6
  92. package/src/main.tsx +0 -25
  93. package/src/pages/Accounts.tsx +0 -205
  94. package/src/pages/Activities.tsx +0 -251
  95. package/src/pages/Agent.tsx +0 -237
  96. package/src/pages/AgentSettings.tsx +0 -544
  97. package/src/pages/Assignments.tsx +0 -750
  98. package/src/pages/Contacts.tsx +0 -200
  99. package/src/pages/Dashboard.tsx +0 -143
  100. package/src/pages/Inbox.tsx +0 -615
  101. package/src/pages/NotFound.tsx +0 -24
  102. package/src/pages/Opportunities.tsx +0 -386
  103. package/src/pages/SearchResults.tsx +0 -49
  104. package/src/pages/Settings.tsx +0 -1884
  105. package/src/pages/UseCases.tsx +0 -396
  106. package/src/pages/auth/Login.tsx +0 -261
  107. package/src/pages/hitl/HITL.tsx +0 -101
  108. package/src/store/appStore.ts +0 -103
  109. package/src/vite-env.d.ts +0 -14
  110. package/tailwind.config.js +0 -121
  111. package/tsconfig.json +0 -24
  112. package/vite.config.ts +0 -27
  113. /package/{public → dist}/android-chrome-192x192.png +0 -0
  114. /package/{public → dist}/android-chrome-512x512.png +0 -0
  115. /package/{public → dist}/apple-touch-icon.png +0 -0
  116. /package/{src/assets/crmy-logo.png → dist/assets/crmy-logo-DWN0xBPW.png} +0 -0
  117. /package/{public → dist}/favicon-16x16.png +0 -0
  118. /package/{public → dist}/favicon-32x32.png +0 -0
  119. /package/{public → dist}/favicon.ico +0 -0
  120. /package/{public → dist}/favicon.svg +0 -0
  121. /package/{public → dist}/site.webmanifest +0 -0
@@ -1,1884 +0,0 @@
1
- // Copyright 2026 CRMy Contributors
2
- // SPDX-License-Identifier: Apache-2.0
3
-
4
- import React, { useState, useMemo, useEffect } from 'react';
5
- import { TopBar } from '@/components/layout/TopBar';
6
- import { Link, Route, Routes, useLocation } from 'react-router-dom';
7
- import { CircleUser, Lock, Link2, ListFilter, Copy, Trash2, Plus, Palette, Database, CheckCircle2, XCircle, Users, Pencil, Eye, EyeOff, LayoutGrid, List, ChevronUp, ChevronDown, ChevronRight, Bot, Key, Search, X } from 'lucide-react';
8
- import { toast } from '@/hooks/use-toast';
9
- import { useAppStore } from '@/store/appStore';
10
- import { ListToolbar, type FilterConfig, type SortOption } from '@/components/crm/ListToolbar';
11
- import { PaginationBar } from '@/components/crm/PaginationBar';
12
- import { motion, AnimatePresence } from 'framer-motion';
13
- import { useIsMobile } from '@/hooks/use-mobile';
14
- import { getUser } from '@/api/client';
15
- import { useApiKeys, useCreateApiKey, useUpdateApiKey, useRevokeApiKey, useActors, useUpdateProfile, useWebhooks, useCreateWebhook, useDeleteWebhook, useCustomFields, useCreateCustomField, useUpdateCustomField, useDeleteCustomField, useDbConfig, useTestDbConfig, useSaveDbConfig, useUsers, useCreateUser, useUpdateUser, useDeleteUser } from '@/api/hooks';
16
- import { useAgentSettings } from '@/contexts/AgentSettingsContext';
17
- import AgentSettings from '@/pages/AgentSettings';
18
- import ActorsSettings from '@/components/settings/ActorsSettings';
19
-
20
- type NavRole = 'member' | 'admin' | 'owner';
21
-
22
- const settingsNavConfig: { icon: React.ElementType; label: string; path: string; roles: NavRole[] }[] = [
23
- { icon: CircleUser, label: 'Profile', path: '/settings', roles: ['member', 'admin', 'owner'] },
24
- { icon: Palette, label: 'Appearance', path: '/settings/appearance', roles: ['member', 'admin', 'owner'] },
25
- { icon: Lock, label: 'API Keys', path: '/settings/api-keys', roles: ['member', 'admin', 'owner'] },
26
- { icon: Link2, label: 'Webhooks', path: '/settings/webhooks', roles: ['admin', 'owner'] },
27
- { icon: ListFilter, label: 'Custom Fields', path: '/settings/custom-fields',roles: ['admin', 'owner'] },
28
- { icon: Users, label: 'Actors', path: '/settings/actors', roles: ['admin', 'owner'] },
29
- { icon: Bot, label: 'Local AI Agent', path: '/settings/agent', roles: ['admin', 'owner'] },
30
- { icon: Database, label: 'Database', path: '/settings/database', roles: ['admin', 'owner'] },
31
- ];
32
-
33
- function AccessDenied() {
34
- return (
35
- <div className="flex flex-col items-center justify-center py-16 gap-3 text-muted-foreground">
36
- <Lock className="w-8 h-8 opacity-25" />
37
- <p className="text-sm font-semibold text-foreground">Access restricted</p>
38
- <p className="text-xs">You don't have permission to view this section.</p>
39
- </div>
40
- );
41
- }
42
-
43
- function RequireRole({ roles, children }: { roles: NavRole[]; children: React.ReactNode }) {
44
- const user = getUser();
45
- if (!user || !roles.includes(user.role as NavRole)) return <AccessDenied />;
46
- return <>{children}</>;
47
- }
48
-
49
- function ProfileSettings() {
50
- const user = getUser();
51
- const updateProfile = useUpdateProfile();
52
-
53
- const [name, setName] = useState(user?.name ?? '');
54
- const [email, setEmail] = useState(user?.email ?? '');
55
- const [currentPassword, setCurrentPassword] = useState('');
56
- const [newPassword, setNewPassword] = useState('');
57
- const [confirmPassword, setConfirmPassword] = useState('');
58
- const [showPasswords, setShowPasswords] = useState(false);
59
- const [saved, setSaved] = useState(false);
60
-
61
- const isOwner = user?.role === 'owner';
62
- const isAdmin = user?.role === 'admin' || isOwner;
63
-
64
- const inputCls = 'w-full h-10 px-3 rounded-lg border border-border bg-background text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring transition-colors';
65
- const readonlyCls = 'w-full h-10 px-3 flex items-center rounded-lg border border-border bg-muted/50 text-sm text-foreground';
66
-
67
- const handleSave = async () => {
68
- if (newPassword && newPassword !== confirmPassword) {
69
- toast({ title: 'Passwords do not match', variant: 'destructive' });
70
- return;
71
- }
72
- if (newPassword && newPassword.length < 8) {
73
- toast({ title: 'Password too short', description: 'Must be at least 8 characters.', variant: 'destructive' });
74
- return;
75
- }
76
- try {
77
- const payload: { name?: string; email?: string; current_password?: string; new_password?: string } = {};
78
- if (name.trim() && name.trim() !== user?.name) payload.name = name.trim();
79
- if (email.trim() && email.trim() !== user?.email) payload.email = email.trim();
80
- if (newPassword) { payload.current_password = currentPassword; payload.new_password = newPassword; }
81
- if (Object.keys(payload).length === 0) {
82
- toast({ title: 'No changes to save' });
83
- return;
84
- }
85
- const updated = await updateProfile.mutateAsync(payload);
86
- // Update localStorage so the topbar reflects changes immediately
87
- const stored = localStorage.getItem('crmy_user');
88
- if (stored) {
89
- const parsed = JSON.parse(stored);
90
- localStorage.setItem('crmy_user', JSON.stringify({ ...parsed, name: updated.name, email: updated.email }));
91
- }
92
- setCurrentPassword('');
93
- setNewPassword('');
94
- setConfirmPassword('');
95
- setSaved(true);
96
- setTimeout(() => setSaved(false), 3000);
97
- toast({ title: 'Profile updated' });
98
- } catch (err) {
99
- toast({ title: 'Failed to update profile', description: err instanceof Error ? err.message : 'Please try again.', variant: 'destructive' });
100
- }
101
- };
102
-
103
- const roleBadge: Record<string, string> = {
104
- owner: 'bg-accent/15 text-accent border-accent/30',
105
- admin: 'bg-primary/15 text-primary border-primary/30',
106
- member: 'bg-muted text-muted-foreground border-border',
107
- };
108
-
109
- return (
110
- <div className="max-w-lg">
111
- <h2 className="font-display font-bold text-lg text-foreground mb-1">Profile</h2>
112
- <p className="text-sm text-muted-foreground mb-6">Update your name, email, and password.</p>
113
-
114
- <div className="space-y-5">
115
- {/* Read-only: Role */}
116
- <div className="space-y-1.5">
117
- <label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Role</label>
118
- <div className={readonlyCls}>
119
- <span className={`text-[11px] font-semibold px-2 py-0.5 rounded-full border capitalize ${roleBadge[user?.role ?? 'member'] ?? roleBadge.member}`}>
120
- {user?.role ?? '—'}
121
- </span>
122
- </div>
123
- </div>
124
-
125
- {/* Editable: Name */}
126
- <div className="space-y-1.5">
127
- <label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Name</label>
128
- <input value={name} onChange={e => setName(e.target.value)} className={inputCls} placeholder="Your full name" />
129
- </div>
130
-
131
- {/* Editable: Email (admin/owner only) */}
132
- <div className="space-y-1.5">
133
- <label className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-2">
134
- Email
135
- {!isAdmin && <span className="text-[10px] text-muted-foreground/60 normal-case font-normal">contact an admin to change</span>}
136
- </label>
137
- {isAdmin ? (
138
- <input type="email" value={email} onChange={e => setEmail(e.target.value)} className={inputCls} placeholder="you@example.com" />
139
- ) : (
140
- <div className={readonlyCls}>{user?.email}</div>
141
- )}
142
- </div>
143
-
144
- {/* Password change */}
145
- <div className="pt-2 border-t border-border space-y-4">
146
- <div className="flex items-center justify-between">
147
- <p className="text-sm font-semibold text-foreground">Change Password</p>
148
- <button onClick={() => setShowPasswords(p => !p)} className="text-xs text-primary hover:underline">
149
- {showPasswords ? 'Cancel' : 'Change'}
150
- </button>
151
- </div>
152
- {showPasswords && (
153
- <div className="space-y-3">
154
- <div className="space-y-1.5">
155
- <label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Current Password</label>
156
- <input type="password" value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} className={inputCls} placeholder="Enter current password" />
157
- </div>
158
- <div className="space-y-1.5">
159
- <label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">New Password</label>
160
- <input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} className={inputCls} placeholder="Min. 8 characters" />
161
- </div>
162
- <div className="space-y-1.5">
163
- <label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Confirm New Password</label>
164
- <input type="password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} className={inputCls} placeholder="Repeat new password"
165
- onKeyDown={e => e.key === 'Enter' && handleSave()} />
166
- {newPassword && confirmPassword && newPassword !== confirmPassword && (
167
- <p className="text-xs text-destructive mt-1">Passwords do not match</p>
168
- )}
169
- </div>
170
- </div>
171
- )}
172
- </div>
173
-
174
- <div className="flex items-center gap-3 pt-1">
175
- <button onClick={handleSave} disabled={updateProfile.isPending}
176
- className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-semibold hover:bg-primary/90 disabled:opacity-40 transition-colors">
177
- {updateProfile.isPending ? 'Saving…' : 'Save Changes'}
178
- </button>
179
- {saved && <span className="text-xs text-success flex items-center gap-1"><CheckCircle2 className="w-3.5 h-3.5" /> Saved</span>}
180
- </div>
181
- </div>
182
- </div>
183
- );
184
- }
185
-
186
- function AppearanceSettings() {
187
- const { darkVariant, setDarkVariant } = useAppStore();
188
- const variants: { key: 'warm' | 'charcoal'; label: string; description: string; preview: string }[] = [
189
- { key: 'warm', label: 'Warm Brown', description: 'Dark theme with warm, earthy brown tones', preview: 'bg-[hsl(15,25%,7%)]' },
190
- { key: 'charcoal', label: 'Charcoal', description: 'Dark theme with cool, navy-charcoal tones', preview: 'bg-[hsl(220,16%,8%)]' },
191
- ];
192
- return (
193
- <div>
194
- <h2 className="font-display font-bold text-lg text-foreground mb-2">Appearance</h2>
195
- <p className="text-sm text-muted-foreground mb-6">Choose your preferred dark mode style.</p>
196
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-md">
197
- {variants.map((v) => (
198
- <button key={v.key} onClick={() => setDarkVariant(v.key)}
199
- className={`flex flex-col gap-3 p-4 rounded-xl border-2 transition-all text-left ${darkVariant === v.key ? 'border-primary bg-primary/5' : 'border-border hover:border-muted-foreground/30'}`}>
200
- <div className={`w-full h-16 rounded-lg ${v.preview} border border-border/30 flex items-end p-2`}>
201
- <div className="flex gap-1">
202
- <div className="w-6 h-2 rounded-full bg-[hsl(24,95%,53%)]" />
203
- <div className="w-4 h-2 rounded-full bg-white/20" />
204
- </div>
205
- </div>
206
- <div>
207
- <p className="text-sm font-semibold text-foreground">{v.label}</p>
208
- <p className="text-xs text-muted-foreground">{v.description}</p>
209
- </div>
210
- </button>
211
- ))}
212
- </div>
213
- </div>
214
- );
215
- }
216
-
217
- const API_KEY_SCOPE_GROUPS = [
218
- { label: 'General', scopes: [
219
- { value: 'read', label: 'Read all' },
220
- { value: 'write', label: 'Write all' },
221
- ]},
222
- { label: 'Contacts', scopes: [
223
- { value: 'contacts:read', label: 'Read' },
224
- { value: 'contacts:write', label: 'Write' },
225
- ]},
226
- { label: 'Accounts', scopes: [
227
- { value: 'accounts:read', label: 'Read' },
228
- { value: 'accounts:write', label: 'Write' },
229
- ]},
230
- { label: 'Opportunities', scopes: [
231
- { value: 'opportunities:read', label: 'Read' },
232
- { value: 'opportunities:write', label: 'Write' },
233
- ]},
234
- { label: 'Activities', scopes: [
235
- { value: 'activities:read', label: 'Read' },
236
- { value: 'activities:write', label: 'Write' },
237
- ]},
238
- { label: 'Assignments', scopes: [
239
- { value: 'assignments:create', label: 'Create' },
240
- { value: 'assignments:update', label: 'Update' },
241
- ]},
242
- { label: 'Context', scopes: [
243
- { value: 'context:read', label: 'Read' },
244
- { value: 'context:write', label: 'Write' },
245
- ]},
246
- ];
247
-
248
- const ALL_SCOPES = API_KEY_SCOPE_GROUPS.flatMap(g => g.scopes);
249
-
250
- function ApiKeysSettings() {
251
- const { data, isLoading } = useApiKeys();
252
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
253
- const { data: actorsData } = useActors({ is_active: true, limit: 100 }) as any;
254
- const createKey = useCreateApiKey();
255
- const updateKey = useUpdateApiKey();
256
- const revokeKey = useRevokeApiKey();
257
-
258
- const [view, setView] = useState<'table' | 'card'>('table');
259
- const [search, setSearch] = useState('');
260
- const [scopeFilter, setScopeFilter] = useState('');
261
- const [usageFilter, setUsageFilter] = useState<'all' | 'used' | 'never'>('all');
262
- const [sort, setSort] = useState<{ key: string; dir: 'asc' | 'desc' }>({ key: 'created_at', dir: 'desc' });
263
- const [page, setPage] = useState(1);
264
-
265
- const [showCreate, setShowCreate] = useState(false);
266
- const [newLabel, setNewLabel] = useState('');
267
- const [selectedScopes, setSelectedScopes] = useState<string[]>(['read', 'write']);
268
- const [newActorId, setNewActorId] = useState('');
269
- const [newExpiresAt, setNewExpiresAt] = useState('');
270
-
271
- const [revealedKey, setRevealedKey] = useState<string | null>(null);
272
- const [revokeId, setRevokeId] = useState<string | null>(null);
273
- const [expandedKeyId, setExpandedKeyId] = useState<string | null>(null);
274
- const [editingKeyId, setEditingKeyId] = useState<string | null>(null);
275
- const [editLabel, setEditLabel] = useState('');
276
- const [editActorId, setEditActorId] = useState('');
277
- const [editExpiresAt, setEditExpiresAt] = useState('');
278
- const [editScopes, setEditScopes] = useState<string[]>([]);
279
- const [editingScopes, setEditingScopes] = useState<string | null>(null); // keyId whose scopes panel is in edit mode
280
-
281
- const PAGE_SIZE = 10;
282
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
283
- const keys: any[] = (data as any)?.data ?? [];
284
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
285
- const actors: any[] = actorsData?.data ?? [];
286
-
287
- const filtered = useMemo(() => {
288
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
289
- let result: any[] = [...keys];
290
- if (search) {
291
- const q = search.toLowerCase();
292
- result = result.filter(k => k.label?.toLowerCase().includes(q) || k.actor_name?.toLowerCase().includes(q));
293
- }
294
- if (scopeFilter) result = result.filter(k => k.scopes?.includes(scopeFilter));
295
- if (usageFilter === 'used') result = result.filter(k => !!k.last_used_at);
296
- if (usageFilter === 'never') result = result.filter(k => !k.last_used_at);
297
- result.sort((a, b) => {
298
- const va: string = sort.key === 'label' ? (a.label ?? '') : (a[sort.key] ?? '');
299
- const vb: string = sort.key === 'label' ? (b.label ?? '') : (b[sort.key] ?? '');
300
- return sort.dir === 'asc' ? va.localeCompare(vb) : vb.localeCompare(va);
301
- });
302
- return result;
303
- }, [keys, search, scopeFilter, usageFilter, sort]);
304
-
305
- const paginated = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
306
-
307
- const resetCreate = () => { setShowCreate(false); setNewLabel(''); setSelectedScopes(['read', 'write']); setNewActorId(''); setNewExpiresAt(''); };
308
-
309
- const handleCreate = async () => {
310
- if (!newLabel.trim() || selectedScopes.length === 0) return;
311
- try {
312
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
313
- const payload: any = { label: newLabel.trim(), scopes: selectedScopes };
314
- if (newActorId) payload.actor_id = newActorId;
315
- if (newExpiresAt) payload.expires_at = new Date(newExpiresAt).toISOString();
316
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
317
- const result = await createKey.mutateAsync(payload) as any;
318
- setRevealedKey(result.key ?? null);
319
- resetCreate();
320
- toast({ title: 'API key created', description: "Copy and store it safely — it won't be shown again." });
321
- } catch (err) {
322
- toast({ title: 'Failed to create API key', description: err instanceof Error ? err.message : 'Please try again.', variant: 'destructive' });
323
- }
324
- };
325
-
326
- const handleRevoke = async (id: string) => {
327
- try {
328
- await revokeKey.mutateAsync(id);
329
- setRevokeId(null);
330
- toast({ title: 'API key revoked' });
331
- } catch (err) {
332
- toast({ title: 'Failed to revoke key', description: err instanceof Error ? err.message : 'Please try again.', variant: 'destructive' });
333
- }
334
- };
335
-
336
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
337
- const startEdit = (k: any) => {
338
- setEditingKeyId(k.id);
339
- setEditLabel(k.label ?? '');
340
- setEditActorId(k.actor_id ?? '');
341
- setEditExpiresAt(k.expires_at ? new Date(k.expires_at).toISOString().slice(0, 10) : '');
342
- setExpandedKeyId(null);
343
- };
344
-
345
- const cancelEdit = () => { setEditingKeyId(null); };
346
-
347
- const handleUpdate = async () => {
348
- if (!editingKeyId || !editLabel.trim()) return;
349
- try {
350
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
351
- const payload: any = { id: editingKeyId, label: editLabel.trim() };
352
- payload.actor_id = editActorId || null;
353
- payload.expires_at = editExpiresAt ? new Date(editExpiresAt).toISOString() : null;
354
- await updateKey.mutateAsync(payload);
355
- setEditingKeyId(null);
356
- toast({ title: 'API key updated' });
357
- } catch (err) {
358
- toast({ title: 'Failed to update API key', description: err instanceof Error ? err.message : 'Please try again.', variant: 'destructive' });
359
- }
360
- };
361
-
362
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
363
- const startEditScopes = (k: any) => {
364
- setEditingScopes(k.id);
365
- setEditScopes(k.scopes ?? []);
366
- };
367
-
368
- const handleSaveScopes = async (keyId: string) => {
369
- try {
370
- await updateKey.mutateAsync({ id: keyId, scopes: editScopes });
371
- setEditingScopes(null);
372
- toast({ title: 'Scopes updated' });
373
- } catch (err) {
374
- toast({ title: 'Failed to update scopes', description: err instanceof Error ? err.message : 'Please try again.', variant: 'destructive' });
375
- }
376
- };
377
-
378
- const toggleScope = (scope: string) =>
379
- setSelectedScopes(prev => prev.includes(scope) ? prev.filter(s => s !== scope) : [...prev, scope]);
380
-
381
- const toggleEditScope = (scope: string) =>
382
- setEditScopes(prev => prev.includes(scope) ? prev.filter(s => s !== scope) : [...prev, scope]);
383
-
384
- const fmtDate = (d?: string) => d ? new Date(d).toLocaleDateString() : null;
385
- const fmtLastUsed = (d?: string) => {
386
- if (!d) return 'Never used';
387
- const days = Math.floor((Date.now() - new Date(d).getTime()) / 86400000);
388
- if (days === 0) return 'Used today';
389
- if (days === 1) return 'Used yesterday';
390
- if (days < 30) return `Used ${days}d ago`;
391
- return `Used ${fmtDate(d)}`;
392
- };
393
-
394
- const SortBtn = ({ sk, label }: { sk: string; label: string }) => (
395
- <button
396
- onClick={() => { setSort(s => s.key === sk ? { key: sk, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key: sk, dir: 'desc' }); setPage(1); }}
397
- className="flex items-center gap-1 text-xs font-mono text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
398
- >
399
- {label}
400
- {sort.key === sk && (sort.dir === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
401
- </button>
402
- );
403
-
404
- const ActorBadge = ({ name, type }: { name: string; type: string }) => (
405
- <div className="flex items-center gap-1.5">
406
- <span className="text-sm text-foreground truncate">{name}</span>
407
- <span className={`text-[10px] px-1.5 py-0.5 rounded border capitalize flex-shrink-0 ${type === 'agent' ? 'bg-blue-500/10 text-blue-500 border-blue-500/30' : 'bg-amber-500/10 text-amber-600 border-amber-500/30'}`}>{type}</span>
408
- </div>
409
- );
410
-
411
- return (
412
- <div className="space-y-4">
413
- {/* Header */}
414
- <div>
415
- <h2 className="font-display font-bold text-lg text-foreground">API Keys</h2>
416
- <p className="text-sm text-muted-foreground mt-0.5">Manage access tokens for the CRMy REST API and MCP server.</p>
417
- </div>
418
-
419
- {/* Revealed key banner */}
420
- {revealedKey && (
421
- <div className="p-4 rounded-xl border border-success/30 bg-success/5">
422
- <p className="text-xs font-semibold text-success mb-2">Your new API key — copy it now, it won't be shown again:</p>
423
- <div className="flex items-center gap-2">
424
- <code className="flex-1 text-xs font-mono bg-background rounded px-2 py-1.5 border border-border truncate">{revealedKey}</code>
425
- <button onClick={() => { navigator.clipboard.writeText(revealedKey!); toast({ title: 'Copied!' }); }} className="p-1.5 rounded-lg hover:bg-muted transition-colors flex-shrink-0">
426
- <Copy className="w-3.5 h-3.5 text-muted-foreground" />
427
- </button>
428
- </div>
429
- <button onClick={() => setRevealedKey(null)} className="mt-2 text-xs text-muted-foreground hover:text-foreground">Dismiss</button>
430
- </div>
431
- )}
432
-
433
- {/* Create form */}
434
- {showCreate && (
435
- <div className="p-5 rounded-xl border border-border bg-card space-y-4">
436
- <div className="flex items-center justify-between">
437
- <h3 className="text-sm font-semibold text-foreground">Create new API key</h3>
438
- <button onClick={resetCreate} className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground transition-colors"><X className="w-4 h-4" /></button>
439
- </div>
440
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
441
- <div className="space-y-1.5">
442
- <label className="text-xs font-mono text-muted-foreground uppercase tracking-wider">Label <span className="text-destructive">*</span></label>
443
- <input value={newLabel} onChange={e => setNewLabel(e.target.value)} placeholder="e.g. Production, CI/CD"
444
- className="w-full h-9 px-3 rounded-lg border border-border bg-background text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring"
445
- onKeyDown={e => e.key === 'Enter' && handleCreate()} autoFocus />
446
- </div>
447
- <div className="space-y-1.5">
448
- <label className="text-xs font-mono text-muted-foreground uppercase tracking-wider">Bind to Actor (optional)</label>
449
- <select value={newActorId} onChange={e => setNewActorId(e.target.value)}
450
- className="w-full h-9 px-3 rounded-lg border border-border bg-background text-sm text-foreground outline-none focus:ring-1 focus:ring-ring">
451
- <option value="">No actor binding</option>
452
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
453
- {actors.map((a: any) => <option key={a.id} value={a.id}>{a.display_name} ({a.actor_type})</option>)}
454
- </select>
455
- </div>
456
- <div className="space-y-1.5">
457
- <label className="text-xs font-mono text-muted-foreground uppercase tracking-wider">Expires (optional)</label>
458
- <input type="date" value={newExpiresAt} onChange={e => setNewExpiresAt(e.target.value)}
459
- className="w-full h-9 px-3 rounded-lg border border-border bg-background text-sm text-foreground outline-none focus:ring-1 focus:ring-ring" />
460
- </div>
461
- </div>
462
- <div className="space-y-2">
463
- <label className="text-xs font-mono text-muted-foreground uppercase tracking-wider">Scopes <span className="text-destructive">*</span></label>
464
- <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-3">
465
- {API_KEY_SCOPE_GROUPS.map(group => (
466
- <div key={group.label} className="space-y-1.5">
467
- <p className="text-xs font-semibold text-muted-foreground">{group.label}</p>
468
- {group.scopes.map(scope => (
469
- <label key={scope.value} className="flex items-center gap-2 cursor-pointer">
470
- <input type="checkbox" checked={selectedScopes.includes(scope.value)} onChange={() => toggleScope(scope.value)}
471
- className="w-3.5 h-3.5 rounded border-border accent-primary" />
472
- <span className="text-xs text-foreground">{scope.label}</span>
473
- </label>
474
- ))}
475
- </div>
476
- ))}
477
- </div>
478
- </div>
479
- <div className="flex gap-2 pt-1">
480
- <button onClick={handleCreate} disabled={!newLabel.trim() || selectedScopes.length === 0 || createKey.isPending}
481
- className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-semibold hover:bg-primary/90 disabled:opacity-40 transition-colors">
482
- {createKey.isPending ? 'Creating…' : 'Create Key'}
483
- </button>
484
- <button onClick={resetCreate} className="px-4 py-2 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted transition-colors">Cancel</button>
485
- </div>
486
- </div>
487
- )}
488
-
489
- {/* Toolbar */}
490
- <div className="flex items-center gap-2 flex-wrap">
491
- <div className="relative flex-1 min-w-0 max-w-xs">
492
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
493
- <input value={search} onChange={e => { setSearch(e.target.value); setPage(1); }} placeholder="Search keys…"
494
- className="w-full h-9 pl-9 pr-3 rounded-xl border border-border bg-card text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-2 focus:ring-primary/30 transition-all" />
495
- </div>
496
- <select value={scopeFilter} onChange={e => { setScopeFilter(e.target.value); setPage(1); }}
497
- className="h-9 px-3 rounded-xl border border-border bg-card text-sm text-muted-foreground outline-none focus:ring-2 focus:ring-primary/30 flex-shrink-0">
498
- <option value="">All scopes</option>
499
- {ALL_SCOPES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
500
- </select>
501
- <select value={usageFilter} onChange={e => { setUsageFilter(e.target.value as 'all' | 'used' | 'never'); setPage(1); }}
502
- className="h-9 px-3 rounded-xl border border-border bg-card text-sm text-muted-foreground outline-none focus:ring-2 focus:ring-primary/30 flex-shrink-0">
503
- <option value="all">All usage</option>
504
- <option value="used">Used</option>
505
- <option value="never">Never used</option>
506
- </select>
507
- <select value={`${sort.key}_${sort.dir}`} onChange={e => { const [k, d] = e.target.value.split('_'); setSort({ key: k, dir: d as 'asc' | 'desc' }); setPage(1); }}
508
- className="h-9 px-3 rounded-xl border border-border bg-card text-sm text-muted-foreground outline-none focus:ring-2 focus:ring-primary/30 flex-shrink-0">
509
- <option value="created_at_desc">Newest first</option>
510
- <option value="created_at_asc">Oldest first</option>
511
- <option value="label_asc">Label A–Z</option>
512
- <option value="label_desc">Label Z–A</option>
513
- <option value="last_used_at_desc">Recently used</option>
514
- <option value="last_used_at_asc">Least used</option>
515
- </select>
516
- <div className="flex items-center border border-border rounded-xl overflow-hidden flex-shrink-0">
517
- <button onClick={() => setView('table')} className={`p-2 transition-colors ${view === 'table' ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/50'}`}><List className="w-4 h-4" /></button>
518
- <button onClick={() => setView('card')} className={`p-2 transition-colors ${view === 'card' ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/50'}`}><LayoutGrid className="w-4 h-4" /></button>
519
- </div>
520
- <button onClick={() => setShowCreate(true)} disabled={showCreate}
521
- className="h-9 px-4 flex items-center gap-1.5 rounded-xl bg-gradient-to-r from-primary to-primary/80 text-primary-foreground text-sm font-semibold hover:shadow-md transition-all flex-shrink-0 press-scale disabled:opacity-50">
522
- <Plus className="w-4 h-4" /> New Key
523
- </button>
524
- </div>
525
-
526
- {/* Content */}
527
- {isLoading ? (
528
- <div className="space-y-2">{[...Array(3)].map((_, i) => <div key={i} className="h-14 bg-muted/50 rounded-2xl animate-pulse" />)}</div>
529
- ) : filtered.length === 0 ? (
530
- <div className="flex flex-col items-center justify-center py-14 text-center">
531
- <Key className="w-8 h-8 text-muted-foreground/30 mb-3" />
532
- <p className="text-sm text-muted-foreground">{keys.length === 0 ? 'No API keys yet. Create one to get started.' : 'No keys match your filters.'}</p>
533
- {keys.length === 0 && <button onClick={() => setShowCreate(true)} className="mt-3 text-xs text-primary hover:underline">Create your first key</button>}
534
- </div>
535
- ) : view === 'table' ? (
536
- <div className="bg-card border border-border rounded-2xl overflow-hidden shadow-sm">
537
- <div className="overflow-x-auto">
538
- <table className="w-full text-sm">
539
- <thead>
540
- <tr className="border-b border-border bg-surface-sunken/50">
541
- <th className="text-left px-4 py-3 text-xs font-display font-semibold text-muted-foreground"><SortBtn sk="label" label="Label" /></th>
542
- <th className="text-left px-4 py-3 hidden md:table-cell text-xs font-display font-semibold text-muted-foreground">Scopes</th>
543
- <th className="text-left px-4 py-3 hidden lg:table-cell text-xs font-display font-semibold text-muted-foreground">Actor</th>
544
- <th className="text-left px-4 py-3 hidden sm:table-cell text-xs font-display font-semibold text-muted-foreground"><SortBtn sk="last_used_at" label="Last Used" /></th>
545
- <th className="text-left px-4 py-3 hidden lg:table-cell text-xs font-display font-semibold text-muted-foreground"><SortBtn sk="created_at" label="Created" /></th>
546
- <th className="px-2 py-3 w-20" />
547
- </tr>
548
- </thead>
549
- <tbody>
550
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
551
- {paginated.map((k: any, i: number) => (
552
- <React.Fragment key={k.id}>
553
- {editingKeyId === k.id ? (
554
- /* ── Inline edit row ── */
555
- <tr className="border-b border-border">
556
- <td colSpan={6} className="p-4 bg-muted/20">
557
- <p className="text-xs font-display font-semibold text-muted-foreground uppercase tracking-wider mb-3">Edit API Key</p>
558
- <div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-3">
559
- <div className="space-y-1">
560
- <label className="text-xs font-medium text-muted-foreground">Label <span className="text-destructive">*</span></label>
561
- <input value={editLabel} onChange={e => setEditLabel(e.target.value)}
562
- className="w-full h-9 px-3 rounded-lg border border-border bg-background text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring"
563
- autoFocus />
564
- </div>
565
- <div className="space-y-1">
566
- <label className="text-xs font-medium text-muted-foreground">Bind to Actor</label>
567
- <select value={editActorId} onChange={e => setEditActorId(e.target.value)}
568
- className="w-full h-9 px-3 rounded-lg border border-border bg-background text-sm text-foreground outline-none focus:ring-1 focus:ring-ring">
569
- <option value="">No actor binding</option>
570
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
571
- {actors.map((a: any) => <option key={a.id} value={a.id}>{a.display_name} ({a.actor_type})</option>)}
572
- </select>
573
- </div>
574
- <div className="space-y-1">
575
- <label className="text-xs font-medium text-muted-foreground">Expires</label>
576
- <input type="date" value={editExpiresAt} onChange={e => setEditExpiresAt(e.target.value)}
577
- className="w-full h-9 px-3 rounded-lg border border-border bg-background text-sm text-foreground outline-none focus:ring-1 focus:ring-ring" />
578
- </div>
579
- </div>
580
- <div className="flex gap-2">
581
- <button onClick={handleUpdate} disabled={!editLabel.trim() || updateKey.isPending}
582
- className="px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90 disabled:opacity-40 transition-colors">
583
- {updateKey.isPending ? 'Saving…' : 'Save Changes'}
584
- </button>
585
- <button onClick={cancelEdit}
586
- className="px-3 py-1.5 rounded-lg bg-muted text-muted-foreground text-xs font-semibold hover:bg-muted/80 transition-colors">
587
- Cancel
588
- </button>
589
- </div>
590
- </td>
591
- </tr>
592
- ) : (
593
- <>
594
- <tr
595
- className={`border-b border-border last:border-0 hover:bg-primary/5 transition-colors group cursor-pointer ${i % 2 === 1 ? 'bg-surface-sunken/30' : ''}`}
596
- onClick={() => { setExpandedKeyId(prev => prev === k.id ? null : k.id); setEditingScopes(null); }}
597
- >
598
- <td className="px-4 py-3">
599
- <div className="flex items-center gap-2">
600
- <div className="w-7 h-7 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
601
- <Key className="w-3.5 h-3.5 text-primary" />
602
- </div>
603
- <div>
604
- <p className="font-semibold text-foreground">{k.label}</p>
605
- <p className="text-[10px] font-mono text-muted-foreground">{k.id.slice(0, 14)}…</p>
606
- </div>
607
- </div>
608
- </td>
609
- <td className="px-4 py-3 hidden md:table-cell" onClick={e => e.stopPropagation()}>
610
- <button
611
- onClick={() => { setExpandedKeyId(prev => prev === k.id ? null : k.id); setEditingScopes(null); }}
612
- className="flex items-center gap-1.5 hover:text-foreground transition-colors"
613
- >
614
- <span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground border border-border font-mono">
615
- {(k.scopes ?? []).length} scope{(k.scopes ?? []).length !== 1 ? 's' : ''}
616
- </span>
617
- <ChevronRight className={`w-3 h-3 text-muted-foreground transition-transform ${expandedKeyId === k.id ? 'rotate-90' : ''}`} />
618
- </button>
619
- </td>
620
- <td className="px-4 py-3 hidden lg:table-cell">
621
- {k.actor_name ? <ActorBadge name={k.actor_name} type={k.actor_type} /> : <span className="text-xs text-muted-foreground">—</span>}
622
- </td>
623
- <td className="px-4 py-3 hidden sm:table-cell">
624
- <span className={`text-xs ${k.last_used_at ? 'text-foreground' : 'text-muted-foreground'}`}>{fmtLastUsed(k.last_used_at)}</span>
625
- </td>
626
- <td className="px-4 py-3 hidden lg:table-cell">
627
- <span className="text-xs text-muted-foreground">{fmtDate(k.created_at)}</span>
628
- {k.expires_at && <p className={`text-[10px] mt-0.5 ${new Date(k.expires_at) < new Date() ? 'text-destructive' : 'text-muted-foreground'}`}>Exp: {fmtDate(k.expires_at)}</p>}
629
- </td>
630
- <td className="px-2 py-3" onClick={e => e.stopPropagation()}>
631
- <div className="flex items-center gap-1 justify-end">
632
- {revokeId === k.id ? (
633
- <div className="flex items-center gap-1.5">
634
- <span className="text-xs text-muted-foreground">Revoke?</span>
635
- <button onClick={() => handleRevoke(k.id)} className="px-2 py-1 rounded bg-destructive text-destructive-foreground text-xs font-semibold hover:bg-destructive/90 transition-colors">Yes</button>
636
- <button onClick={() => setRevokeId(null)} className="px-2 py-1 rounded bg-muted text-muted-foreground text-xs hover:bg-muted/80 transition-colors">No</button>
637
- </div>
638
- ) : (
639
- <>
640
- <button onClick={() => startEdit(k)} className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors opacity-0 group-hover:opacity-100" title="Edit">
641
- <Pencil className="w-3.5 h-3.5" />
642
- </button>
643
- <button onClick={() => setRevokeId(k.id)} className="p-1.5 rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100" title="Revoke">
644
- <Trash2 className="w-3.5 h-3.5" />
645
- </button>
646
- </>
647
- )}
648
- <button
649
- onClick={() => { setExpandedKeyId(prev => prev === k.id ? null : k.id); setEditingScopes(null); }}
650
- className={`p-1.5 rounded-lg transition-colors ${expandedKeyId === k.id ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground hover:bg-muted'}`}
651
- >
652
- <ChevronRight className={`w-3.5 h-3.5 transition-transform ${expandedKeyId === k.id ? 'rotate-90' : ''}`} />
653
- </button>
654
- </div>
655
- </td>
656
- </tr>
657
- <AnimatePresence>
658
- {expandedKeyId === k.id && (
659
- <tr>
660
- <td colSpan={6} className="p-0 border-b border-border last:border-0">
661
- <motion.div
662
- initial={{ opacity: 0, height: 0 }}
663
- animate={{ opacity: 1, height: 'auto' }}
664
- exit={{ opacity: 0, height: 0 }}
665
- className="overflow-hidden"
666
- >
667
- <div className="px-4 py-4 bg-muted/20">
668
- <div className="rounded-lg border border-border bg-card overflow-hidden">
669
- <div className="flex items-center justify-between px-4 py-3 border-b border-border">
670
- <div className="flex items-center gap-2">
671
- <span className="text-sm font-semibold text-foreground">Scopes</span>
672
- <span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground border border-border">
673
- {(k.scopes ?? []).length}
674
- </span>
675
- </div>
676
- {editingScopes !== k.id ? (
677
- <button onClick={() => startEditScopes(k)} className="text-xs font-semibold text-primary hover:underline">Edit</button>
678
- ) : (
679
- <div className="flex gap-2">
680
- <button onClick={() => handleSaveScopes(k.id)} disabled={updateKey.isPending}
681
- className="px-2.5 py-1 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90 disabled:opacity-40">
682
- {updateKey.isPending ? 'Saving…' : 'Save'}
683
- </button>
684
- <button onClick={() => setEditingScopes(null)}
685
- className="px-2.5 py-1 rounded-lg border border-border text-xs font-semibold text-muted-foreground hover:text-foreground">
686
- Cancel
687
- </button>
688
- </div>
689
- )}
690
- </div>
691
- <div className="px-4 py-3">
692
- {editingScopes === k.id ? (
693
- <div className="space-y-3">
694
- {API_KEY_SCOPE_GROUPS.map(group => (
695
- <div key={group.label}>
696
- <p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">{group.label}</p>
697
- <div className="flex flex-wrap gap-1.5">
698
- {group.scopes.map(s => {
699
- const active = editScopes.includes(s.value);
700
- return (
701
- <button key={s.value} onClick={() => toggleEditScope(s.value)}
702
- className={`px-2.5 py-1 rounded-md text-xs font-medium border transition-colors ${active ? 'bg-primary/10 text-primary border-primary/30' : 'bg-muted/50 border-border text-muted-foreground hover:text-foreground'}`}>
703
- {s.label}
704
- </button>
705
- );
706
- })}
707
- </div>
708
- </div>
709
- ))}
710
- </div>
711
- ) : (
712
- <div className="flex flex-wrap gap-1.5">
713
- {(k.scopes ?? []).length === 0 ? (
714
- <p className="text-xs text-muted-foreground">No scopes assigned.</p>
715
- ) : (
716
- (k.scopes ?? []).map((s: string) => (
717
- <span key={s} className="px-2 py-0.5 rounded-md text-[11px] font-medium bg-primary/10 text-primary border border-primary/20">{s}</span>
718
- ))
719
- )}
720
- </div>
721
- )}
722
- </div>
723
- </div>
724
- </div>
725
- </motion.div>
726
- </td>
727
- </tr>
728
- )}
729
- </AnimatePresence>
730
- </>
731
- )}
732
- </React.Fragment>
733
- ))}
734
- </tbody>
735
- </table>
736
- </div>
737
- </div>
738
- ) : (
739
- <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
740
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
741
- {paginated.map((k: any) => (
742
- <div key={k.id} className="bg-card border border-border rounded-xl p-4 space-y-3 hover:shadow-md transition-shadow group">
743
- <div className="flex items-start justify-between gap-2">
744
- <div className="flex items-start gap-2 min-w-0">
745
- <div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
746
- <Key className="w-4 h-4 text-primary" />
747
- </div>
748
- <div className="min-w-0">
749
- <p className="text-sm font-semibold text-foreground truncate">{k.label}</p>
750
- <p className="text-[10px] font-mono text-muted-foreground">{k.id.slice(0, 14)}…</p>
751
- </div>
752
- </div>
753
- {revokeId === k.id ? (
754
- <div className="flex items-center gap-1 flex-shrink-0">
755
- <button onClick={() => handleRevoke(k.id)} className="px-2 py-0.5 rounded bg-destructive text-destructive-foreground text-[10px] font-semibold">Revoke</button>
756
- <button onClick={() => setRevokeId(null)} className="px-2 py-0.5 rounded bg-muted text-muted-foreground text-[10px]">✕</button>
757
- </div>
758
- ) : (
759
- <button onClick={() => setRevokeId(k.id)} className="p-1.5 rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100 flex-shrink-0">
760
- <Trash2 className="w-3.5 h-3.5" />
761
- </button>
762
- )}
763
- </div>
764
- <div className="flex flex-wrap gap-1">
765
- {(k.scopes ?? []).map((s: string) => (
766
- <span key={s} className="px-1.5 py-0.5 rounded text-[10px] font-mono bg-muted text-muted-foreground border border-border">{s}</span>
767
- ))}
768
- </div>
769
- <div className="space-y-1.5 pt-2 border-t border-border">
770
- {k.actor_name && (
771
- <div className="flex items-center gap-1.5">
772
- <span className="text-[10px] text-muted-foreground w-16 flex-shrink-0">Actor</span>
773
- <ActorBadge name={k.actor_name} type={k.actor_type} />
774
- </div>
775
- )}
776
- <div className="flex items-center gap-1.5">
777
- <span className="text-[10px] text-muted-foreground w-16 flex-shrink-0">Last used</span>
778
- <span className={`text-xs ${k.last_used_at ? 'text-foreground' : 'text-muted-foreground'}`}>{fmtLastUsed(k.last_used_at)}</span>
779
- </div>
780
- <div className="flex items-center gap-1.5">
781
- <span className="text-[10px] text-muted-foreground w-16 flex-shrink-0">Created</span>
782
- <span className="text-xs text-muted-foreground">{fmtDate(k.created_at)}</span>
783
- </div>
784
- {k.expires_at && (
785
- <div className="flex items-center gap-1.5">
786
- <span className="text-[10px] text-muted-foreground w-16 flex-shrink-0">Expires</span>
787
- <span className={`text-xs ${new Date(k.expires_at) < new Date() ? 'text-destructive font-medium' : 'text-foreground'}`}>{fmtDate(k.expires_at)}</span>
788
- </div>
789
- )}
790
- </div>
791
- </div>
792
- ))}
793
- </div>
794
- )}
795
-
796
- <PaginationBar page={page} pageSize={PAGE_SIZE} total={filtered.length} onPageChange={setPage} />
797
- </div>
798
- );
799
- }
800
-
801
- function WebhooksSettings() {
802
- const { data, isLoading } = useWebhooks();
803
- const createWebhook = useCreateWebhook();
804
- const deleteWebhook = useDeleteWebhook();
805
- const [showCreate, setShowCreate] = useState(false);
806
- const [newUrl, setNewUrl] = useState('');
807
-
808
- const webhooks = (data as any)?.data ?? [];
809
-
810
- const handleCreate = async () => {
811
- if (!newUrl.trim()) return;
812
- try {
813
- await createWebhook.mutateAsync({ url: newUrl.trim(), events: ['contact.created', 'opportunity.updated'] });
814
- setNewUrl('');
815
- setShowCreate(false);
816
- toast({ title: 'Webhook created' });
817
- } catch {
818
- toast({ title: 'Error', description: 'Failed to create webhook.', variant: 'destructive' });
819
- }
820
- };
821
-
822
- const handleDelete = async (id: string) => {
823
- try {
824
- await deleteWebhook.mutateAsync(id);
825
- toast({ title: 'Webhook deleted' });
826
- } catch {
827
- toast({ title: 'Error', description: 'Failed to delete webhook.', variant: 'destructive' });
828
- }
829
- };
830
-
831
- return (
832
- <div>
833
- <div className="flex items-center justify-between mb-4">
834
- <h2 className="font-display font-bold text-lg text-foreground">Webhooks</h2>
835
- <button onClick={() => setShowCreate(true)} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90 transition-colors">
836
- <Plus className="w-3.5 h-3.5" /> New Webhook
837
- </button>
838
- </div>
839
-
840
- {showCreate && (
841
- <div className="mb-4 p-4 rounded-xl border border-border bg-muted/30 flex items-center gap-2 max-w-lg">
842
- <input value={newUrl} onChange={(e) => setNewUrl(e.target.value)}
843
- placeholder="https://your-server.com/webhook"
844
- className="flex-1 h-9 px-3 rounded-lg border border-border bg-background text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring"
845
- onKeyDown={(e) => e.key === 'Enter' && handleCreate()} />
846
- <button onClick={handleCreate} disabled={!newUrl.trim() || createWebhook.isPending}
847
- className="px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90 disabled:opacity-40 transition-colors">
848
- Create
849
- </button>
850
- <button onClick={() => { setShowCreate(false); setNewUrl(''); }} className="px-3 py-1.5 rounded-lg bg-muted text-muted-foreground text-xs font-semibold">Cancel</button>
851
- </div>
852
- )}
853
-
854
- <div className="space-y-2 max-w-2xl">
855
- {isLoading ? (
856
- <div className="space-y-2">{[...Array(2)].map((_, i) => <div key={i} className="h-14 bg-muted/50 rounded-xl animate-pulse" />)}</div>
857
- ) : webhooks.length === 0 ? (
858
- <p className="text-sm text-muted-foreground py-4">No webhooks configured.</p>
859
- ) : webhooks.map((wh: any) => (
860
- <div key={wh.id} className="flex items-center gap-3 p-3 rounded-xl border border-border bg-card">
861
- <div className="flex-1 min-w-0">
862
- <p className="text-sm font-semibold text-foreground truncate">{wh.url}</p>
863
- {wh.events && (
864
- <div className="flex flex-wrap gap-1 mt-1">
865
- {(wh.events as string[]).map((ev) => (
866
- <span key={ev} className="px-1.5 py-0.5 rounded text-[10px] font-mono bg-muted text-muted-foreground">{ev}</span>
867
- ))}
868
- </div>
869
- )}
870
- </div>
871
- <button onClick={() => handleDelete(wh.id)} className="p-1.5 rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors">
872
- <Trash2 className="w-3.5 h-3.5" />
873
- </button>
874
- </div>
875
- ))}
876
- </div>
877
- </div>
878
- );
879
- }
880
-
881
- const objectTypes = [
882
- { key: 'contact', label: 'Contact' },
883
- { key: 'account', label: 'Account' },
884
- { key: 'opportunity', label: 'Opportunity' },
885
- { key: 'use_case', label: 'Use Case' },
886
- { key: 'activity', label: 'Activity' },
887
- ];
888
-
889
- const FIELD_TYPE_OPTIONS: { value: string; label: string; color: string }[] = [
890
- { value: 'text', label: 'Text', color: 'bg-blue-500/10 text-blue-400 border-blue-500/20' },
891
- { value: 'number', label: 'Number', color: 'bg-purple-500/10 text-purple-400 border-purple-500/20' },
892
- { value: 'boolean', label: 'Checkbox', color: 'bg-green-500/10 text-green-400 border-green-500/20' },
893
- { value: 'date', label: 'Date', color: 'bg-orange-500/10 text-orange-400 border-orange-500/20' },
894
- { value: 'select', label: 'Dropdown', color: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20' },
895
- { value: 'multi_select', label: 'Multi-select', color: 'bg-pink-500/10 text-pink-400 border-pink-500/20' },
896
- ];
897
-
898
- function fieldTypeColor(type: string) {
899
- return FIELD_TYPE_OPTIONS.find(o => o.value === type)?.color ?? 'bg-muted text-muted-foreground border-border';
900
- }
901
- function fieldTypeLabel(type: string) {
902
- return FIELD_TYPE_OPTIONS.find(o => o.value === type)?.label ?? type;
903
- }
904
- function toFieldKey(label: string) {
905
- return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
906
- }
907
-
908
- function CustomFieldsSettings() {
909
- const [activeTab, setActiveTab] = useState('contact');
910
- const [showCreate, setShowCreate] = useState(false);
911
- const [newLabel, setNewLabel] = useState('');
912
- const [newType, setNewType] = useState('text');
913
- const [newRequired, setNewRequired] = useState(false);
914
- const [newOptions, setNewOptions] = useState('');
915
- const [editingId, setEditingId] = useState<string | null>(null);
916
- const [editLabel, setEditLabel] = useState('');
917
- const [editRequired, setEditRequired] = useState(false);
918
- const [editOptions, setEditOptions] = useState('');
919
- const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
920
-
921
- const { data, isLoading } = useCustomFields(activeTab);
922
- const createField = useCreateCustomField();
923
- const updateField = useUpdateCustomField();
924
- const deleteField = useDeleteCustomField();
925
-
926
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
927
- const fields: any[] = (data as any)?.fields ?? [];
928
-
929
- const handleCreate = async () => {
930
- if (!newLabel.trim()) return;
931
- try {
932
- const needsOptions = newType === 'select' || newType === 'multi_select';
933
- const options = needsOptions && newOptions.trim()
934
- ? newOptions.split(',').map(o => o.trim()).filter(Boolean)
935
- : undefined;
936
- await createField.mutateAsync({
937
- label: newLabel.trim(),
938
- field_name: toFieldKey(newLabel),
939
- field_type: newType,
940
- object_type: activeTab,
941
- required: newRequired,
942
- ...(options ? { options } : {}),
943
- });
944
- setNewLabel(''); setNewType('text'); setNewRequired(false); setNewOptions('');
945
- setShowCreate(false);
946
- toast({ title: 'Custom field created' });
947
- } catch {
948
- toast({ title: 'Error', description: 'Failed to create field.', variant: 'destructive' });
949
- }
950
- };
951
-
952
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
953
- const startEdit = (f: any) => {
954
- setEditingId(f.id);
955
- setEditLabel(f.label ?? '');
956
- setEditRequired(f.is_required ?? false);
957
- setEditOptions(Array.isArray(f.options) ? f.options.join(', ') : '');
958
- setShowCreate(false);
959
- };
960
-
961
- const handleUpdate = async () => {
962
- if (!editingId || !editLabel.trim()) return;
963
- try {
964
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
965
- const field = fields.find((f: any) => f.id === editingId);
966
- const needsOptions = field?.field_type === 'select' || field?.field_type === 'multi_select';
967
- const options = needsOptions && editOptions.trim()
968
- ? editOptions.split(',').map(o => o.trim()).filter(Boolean)
969
- : undefined;
970
- await updateField.mutateAsync({
971
- id: editingId,
972
- label: editLabel.trim(),
973
- required: editRequired,
974
- ...(options !== undefined ? { options } : {}),
975
- });
976
- setEditingId(null);
977
- toast({ title: 'Custom field updated' });
978
- } catch {
979
- toast({ title: 'Error', description: 'Failed to update field.', variant: 'destructive' });
980
- }
981
- };
982
-
983
- const handleDelete = async (id: string) => {
984
- try {
985
- await deleteField.mutateAsync(id);
986
- setConfirmDeleteId(null);
987
- toast({ title: 'Custom field deleted' });
988
- } catch {
989
- toast({ title: 'Error', description: 'Failed to delete field.', variant: 'destructive' });
990
- }
991
- };
992
-
993
- const inputCls = 'w-full h-9 px-3 rounded-lg border border-border bg-background text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring';
994
-
995
- return (
996
- <div>
997
- <div className="flex items-center justify-between mb-2">
998
- <h2 className="font-display font-bold text-lg text-foreground">Custom Fields</h2>
999
- <button onClick={() => { setShowCreate(true); setEditingId(null); setConfirmDeleteId(null); }}
1000
- className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90 transition-colors">
1001
- <Plus className="w-3.5 h-3.5" /> New Field
1002
- </button>
1003
- </div>
1004
- <p className="text-sm text-muted-foreground mb-5">
1005
- Define custom fields per object type. Values are type-checked and required fields are enforced by the server.
1006
- </p>
1007
-
1008
- {/* Object type tabs */}
1009
- <div className="flex gap-1 mb-5 overflow-x-auto no-scrollbar bg-muted rounded-xl p-0.5">
1010
- {objectTypes.map((ot) => (
1011
- <button key={ot.key} onClick={() => { setActiveTab(ot.key); setShowCreate(false); setEditingId(null); setConfirmDeleteId(null); }}
1012
- className={`px-3 py-1.5 rounded-lg text-xs font-semibold whitespace-nowrap transition-all ${activeTab === ot.key ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}>
1013
- {ot.label}
1014
- </button>
1015
- ))}
1016
- </div>
1017
-
1018
- {/* Create form */}
1019
- {showCreate && (
1020
- <div className="mb-5 p-4 rounded-xl border border-border bg-muted/30 space-y-3 max-w-lg">
1021
- <p className="text-xs font-display font-semibold text-muted-foreground uppercase tracking-wider">New Field</p>
1022
- <div className="space-y-1.5">
1023
- <label className="text-xs font-medium text-muted-foreground">Label <span className="text-destructive">*</span></label>
1024
- <input value={newLabel} onChange={e => setNewLabel(e.target.value)} placeholder="e.g. Preferred Language"
1025
- className={inputCls} onKeyDown={e => e.key === 'Enter' && handleCreate()} />
1026
- {newLabel.trim() && (
1027
- <p className="text-[11px] text-muted-foreground font-mono">key: {toFieldKey(newLabel)}</p>
1028
- )}
1029
- </div>
1030
- <div className="space-y-1.5">
1031
- <label className="text-xs font-medium text-muted-foreground">Type</label>
1032
- <div className="flex flex-wrap gap-1.5">
1033
- {FIELD_TYPE_OPTIONS.map(ft => (
1034
- <button key={ft.value} onClick={() => setNewType(ft.value)}
1035
- className={`px-2.5 py-1 rounded-md text-xs font-medium border transition-colors ${newType === ft.value ? ft.color : 'bg-muted/50 border-border text-muted-foreground hover:text-foreground'}`}>
1036
- {ft.label}
1037
- </button>
1038
- ))}
1039
- </div>
1040
- </div>
1041
- {(newType === 'select' || newType === 'multi_select') && (
1042
- <div className="space-y-1.5">
1043
- <label className="text-xs font-medium text-muted-foreground">Options <span className="text-muted-foreground font-normal">(comma-separated)</span></label>
1044
- <input value={newOptions} onChange={e => setNewOptions(e.target.value)} placeholder="Option A, Option B, Option C"
1045
- className={inputCls} />
1046
- </div>
1047
- )}
1048
- <button
1049
- type="button"
1050
- onClick={() => setNewRequired(!newRequired)}
1051
- className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border text-xs font-semibold transition-all ${
1052
- newRequired
1053
- ? 'bg-primary/10 border-primary/40 text-primary'
1054
- : 'bg-muted border-border text-muted-foreground hover:text-foreground'
1055
- }`}
1056
- >
1057
- <span className={`w-3.5 h-3.5 rounded-sm border flex items-center justify-center flex-shrink-0 transition-all ${
1058
- newRequired ? 'bg-primary border-primary' : 'border-border'
1059
- }`}>
1060
- {newRequired && <svg viewBox="0 0 10 8" className="w-2.5 h-2.5 fill-none stroke-white stroke-[1.5]"><polyline points="1,4 4,7 9,1"/></svg>}
1061
- </span>
1062
- Required field
1063
- </button>
1064
- <div className="flex gap-2 pt-1">
1065
- <button onClick={handleCreate} disabled={!newLabel.trim() || createField.isPending}
1066
- className="px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90 disabled:opacity-40 transition-colors">
1067
- {createField.isPending ? 'Creating…' : 'Create Field'}
1068
- </button>
1069
- <button onClick={() => { setShowCreate(false); setNewLabel(''); setNewRequired(false); setNewOptions(''); }}
1070
- className="px-3 py-1.5 rounded-lg bg-muted text-muted-foreground text-xs font-semibold hover:bg-muted/80 transition-colors">
1071
- Cancel
1072
- </button>
1073
- </div>
1074
- </div>
1075
- )}
1076
-
1077
- {/* Fields list */}
1078
- <div className="space-y-2 max-w-2xl">
1079
- {isLoading ? (
1080
- <div className="space-y-2">{[...Array(3)].map((_, i) => <div key={i} className="h-14 bg-muted/50 rounded-xl animate-pulse" />)}</div>
1081
- ) : fields.length === 0 ? (
1082
- <p className="text-sm text-muted-foreground py-4">No custom fields for this object type yet.</p>
1083
- ) : fields.map((f: any) => (
1084
- <div key={f.id} className="rounded-xl border border-border bg-card overflow-hidden">
1085
- {editingId === f.id ? (
1086
- <div className="p-4 space-y-3">
1087
- <p className="text-xs font-display font-semibold text-muted-foreground uppercase tracking-wider">Edit Field</p>
1088
- <div className="space-y-1.5">
1089
- <label className="text-xs font-medium text-muted-foreground">Label</label>
1090
- <input value={editLabel} onChange={e => setEditLabel(e.target.value)} className={inputCls} />
1091
- </div>
1092
- {(f.field_type === 'select' || f.field_type === 'multi_select') && (
1093
- <div className="space-y-1.5">
1094
- <label className="text-xs font-medium text-muted-foreground">Options <span className="text-muted-foreground font-normal">(comma-separated)</span></label>
1095
- <input value={editOptions} onChange={e => setEditOptions(e.target.value)} placeholder="Option A, Option B" className={inputCls} />
1096
- </div>
1097
- )}
1098
- <button
1099
- type="button"
1100
- onClick={() => setEditRequired(!editRequired)}
1101
- className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border text-xs font-semibold transition-all ${
1102
- editRequired
1103
- ? 'bg-primary/10 border-primary/40 text-primary'
1104
- : 'bg-muted border-border text-muted-foreground hover:text-foreground'
1105
- }`}
1106
- >
1107
- <span className={`w-3.5 h-3.5 rounded-sm border flex items-center justify-center flex-shrink-0 transition-all ${
1108
- editRequired ? 'bg-primary border-primary' : 'border-border'
1109
- }`}>
1110
- {editRequired && <svg viewBox="0 0 10 8" className="w-2.5 h-2.5 fill-none stroke-white stroke-[1.5]"><polyline points="1,4 4,7 9,1"/></svg>}
1111
- </span>
1112
- Required field
1113
- </button>
1114
- <div className="flex gap-2">
1115
- <button onClick={handleUpdate} disabled={!editLabel.trim() || updateField.isPending}
1116
- className="px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90 disabled:opacity-40 transition-colors">
1117
- {updateField.isPending ? 'Saving…' : 'Save'}
1118
- </button>
1119
- <button onClick={() => setEditingId(null)} className="px-3 py-1.5 rounded-lg bg-muted text-muted-foreground text-xs font-semibold hover:bg-muted/80 transition-colors">Cancel</button>
1120
- </div>
1121
- </div>
1122
- ) : confirmDeleteId === f.id ? (
1123
- <div className="p-4 flex items-center gap-3 flex-wrap">
1124
- <p className="text-sm text-foreground flex-1">Delete <strong>{f.label}</strong>? Existing values will remain but won't be validated.</p>
1125
- <button onClick={() => handleDelete(f.id)} disabled={deleteField.isPending}
1126
- className="px-3 py-1.5 rounded-lg bg-destructive text-destructive-foreground text-xs font-semibold hover:bg-destructive/90 disabled:opacity-40 transition-colors">
1127
- {deleteField.isPending ? 'Deleting…' : 'Confirm Delete'}
1128
- </button>
1129
- <button onClick={() => setConfirmDeleteId(null)} className="px-3 py-1.5 rounded-lg bg-muted text-muted-foreground text-xs font-semibold">Cancel</button>
1130
- </div>
1131
- ) : (
1132
- <div className="flex items-center gap-3 px-4 py-3">
1133
- <div className="flex-1 min-w-0">
1134
- <div className="flex items-center gap-2 flex-wrap">
1135
- <p className="text-sm font-semibold text-foreground">{f.label}</p>
1136
- {f.is_required && (
1137
- <span className="text-[10px] px-1.5 py-0.5 rounded border bg-destructive/10 text-destructive border-destructive/20 font-semibold">Required</span>
1138
- )}
1139
- </div>
1140
- <div className="flex items-center gap-2 mt-0.5 flex-wrap">
1141
- <span className={`text-[10px] px-1.5 py-0.5 rounded border font-mono font-medium ${fieldTypeColor(f.field_type)}`}>
1142
- {fieldTypeLabel(f.field_type)}
1143
- </span>
1144
- <span className="text-[10px] text-muted-foreground font-mono">{f.field_key}</span>
1145
- {Array.isArray(f.options) && f.options.length > 0 && (
1146
- <span className="text-[10px] text-muted-foreground">
1147
- {f.options.slice(0, 3).join(', ')}{f.options.length > 3 ? ` +${f.options.length - 3}` : ''}
1148
- </span>
1149
- )}
1150
- </div>
1151
- </div>
1152
- <div className="flex items-center gap-1 flex-shrink-0">
1153
- <button onClick={() => { startEdit(f); setConfirmDeleteId(null); }}
1154
- className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors">
1155
- <Pencil className="w-3.5 h-3.5" />
1156
- </button>
1157
- <button onClick={() => { setConfirmDeleteId(f.id); setEditingId(null); }}
1158
- className="p-1.5 rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors">
1159
- <Trash2 className="w-3.5 h-3.5" />
1160
- </button>
1161
- </div>
1162
- </div>
1163
- )}
1164
- </div>
1165
- ))}
1166
- </div>
1167
- </div>
1168
- );
1169
- }
1170
-
1171
- const PASSWORD_RULES = [
1172
- { label: 'At least 8 characters', test: (p: string) => p.length >= 8 },
1173
- { label: 'One uppercase letter', test: (p: string) => /[A-Z]/.test(p) },
1174
- { label: 'One number', test: (p: string) => /\d/.test(p) },
1175
- { label: 'One special character', test: (p: string) => /[^A-Za-z0-9]/.test(p) },
1176
- ];
1177
- const ROLES = ['member', 'admin', 'owner'] as const;
1178
- type Role = typeof ROLES[number];
1179
- const roleLabels: Record<Role, string> = { member: 'Member', admin: 'Admin', owner: 'Owner' };
1180
-
1181
- function isValidEmail(email: string) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); }
1182
- function isStrongPassword(p: string) { return PASSWORD_RULES.every(r => r.test(p)); }
1183
-
1184
- type UserRow = { id: string; email: string; name: string; role: string; created_at: string };
1185
-
1186
- interface UserFormState {
1187
- name: string; email: string; password: string; role: Role;
1188
- showPassword: boolean; touched: Record<string, boolean>;
1189
- }
1190
-
1191
- function initForm(defaults?: Partial<UserFormState>): UserFormState {
1192
- return { name: '', email: '', password: '', role: 'member', showPassword: false, touched: {}, ...defaults };
1193
- }
1194
-
1195
- function UserForm({
1196
- form, onChange, onTouch, isEdit, currentUserRole,
1197
- }: {
1198
- form: UserFormState;
1199
- onChange: (patch: Partial<UserFormState>) => void;
1200
- onTouch: (field: string) => void;
1201
- isEdit: boolean;
1202
- currentUserRole: string;
1203
- }) {
1204
- const nameErr = form.touched.name && !form.name.trim() ? 'Name is required' : '';
1205
- const emailErr = form.touched.email && !isValidEmail(form.email) ? 'Enter a valid email address' : '';
1206
- const passwordErr = form.touched.password && !isEdit && !isStrongPassword(form.password)
1207
- ? 'Password does not meet requirements'
1208
- : form.touched.password && !isEdit && !form.password ? 'Password is required' : '';
1209
- const optionalPasswordErr = isEdit && form.password && !isStrongPassword(form.password)
1210
- ? 'Password does not meet requirements' : '';
1211
-
1212
- const fieldCls = (err: string) =>
1213
- `w-full h-9 px-3 rounded-lg border bg-background text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring transition-colors ${err ? 'border-destructive focus:ring-destructive' : 'border-border'}`;
1214
-
1215
- return (
1216
- <div className="space-y-3">
1217
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
1218
- <div className="space-y-1">
1219
- <label className="text-xs font-medium text-muted-foreground">Name <span className="text-destructive">*</span></label>
1220
- <input value={form.name} onChange={e => onChange({ name: e.target.value })} onBlur={() => onTouch('name')}
1221
- placeholder="Jane Smith" className={fieldCls(nameErr)} />
1222
- {nameErr && <p className="text-xs text-destructive">{nameErr}</p>}
1223
- </div>
1224
- <div className="space-y-1">
1225
- <label className="text-xs font-medium text-muted-foreground">Email <span className="text-destructive">*</span></label>
1226
- <input type="email" value={form.email} onChange={e => onChange({ email: e.target.value })} onBlur={() => onTouch('email')}
1227
- placeholder="jane@company.com" className={fieldCls(emailErr)} />
1228
- {emailErr && <p className="text-xs text-destructive">{emailErr}</p>}
1229
- </div>
1230
- </div>
1231
-
1232
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
1233
- <div className="space-y-1">
1234
- <label className="text-xs font-medium text-muted-foreground">
1235
- Password {isEdit ? <span className="text-muted-foreground font-normal">(leave blank to keep)</span> : <span className="text-destructive">*</span>}
1236
- </label>
1237
- <div className="relative">
1238
- <input type={form.showPassword ? 'text' : 'password'} value={form.password}
1239
- onChange={e => onChange({ password: e.target.value })} onBlur={() => onTouch('password')}
1240
- placeholder={isEdit ? '••••••••' : 'Min. 8 characters'} className={`${fieldCls(passwordErr || optionalPasswordErr)} pr-9`} />
1241
- <button type="button" onClick={() => onChange({ showPassword: !form.showPassword })}
1242
- className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
1243
- {form.showPassword ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
1244
- </button>
1245
- </div>
1246
- {(form.touched.password || form.password) && (
1247
- <ul className="space-y-0.5 mt-1">
1248
- {PASSWORD_RULES.map(rule => {
1249
- const ok = rule.test(form.password);
1250
- return (
1251
- <li key={rule.label} className={`flex items-center gap-1 text-[11px] ${ok ? 'text-success' : 'text-muted-foreground'}`}>
1252
- <CheckCircle2 className={`w-3 h-3 ${ok ? 'opacity-100' : 'opacity-30'}`} /> {rule.label}
1253
- </li>
1254
- );
1255
- })}
1256
- </ul>
1257
- )}
1258
- {passwordErr && <p className="text-xs text-destructive">{passwordErr}</p>}
1259
- {optionalPasswordErr && <p className="text-xs text-destructive">{optionalPasswordErr}</p>}
1260
- </div>
1261
- <div className="space-y-1">
1262
- <label className="text-xs font-medium text-muted-foreground">Role <span className="text-destructive">*</span></label>
1263
- <select value={form.role} onChange={e => onChange({ role: e.target.value as Role })}
1264
- className="w-full h-9 px-3 rounded-lg border border-border bg-background text-sm text-foreground outline-none focus:ring-1 focus:ring-ring appearance-none">
1265
- {ROLES.filter(r => r !== 'owner' || currentUserRole === 'owner').map(r => (
1266
- <option key={r} value={r}>{roleLabels[r]}</option>
1267
- ))}
1268
- </select>
1269
- <p className="text-[11px] text-muted-foreground">
1270
- {form.role === 'owner' ? 'Full access including billing and account deletion' : form.role === 'admin' ? 'Can manage users, settings, and all data' : 'Can access CRM data only'}
1271
- </p>
1272
- </div>
1273
- </div>
1274
- </div>
1275
- );
1276
- }
1277
-
1278
- function UserAvatar({ name, size = 'sm' }: { name: string; size?: 'sm' | 'lg' }) {
1279
- const initials = name.trim().split(/\s+/).map(n => n[0]).slice(0, 2).join('').toUpperCase() || '?';
1280
- const sz = size === 'lg' ? 'w-10 h-10 text-sm' : 'w-8 h-8 text-xs';
1281
- return (
1282
- <div className={`${sz} rounded-xl bg-primary/15 flex items-center justify-center flex-shrink-0`}>
1283
- <span className="font-display font-bold text-primary">{initials}</span>
1284
- </div>
1285
- );
1286
- }
1287
-
1288
- function UsersSettings() {
1289
- const currentUser = getUser();
1290
- const currentUserRole = currentUser?.role ?? 'member';
1291
- const { data, isLoading } = useUsers();
1292
- const createUser = useCreateUser();
1293
- const updateUser = useUpdateUser();
1294
- const deleteUser = useDeleteUser();
1295
-
1296
- const isMobile = useIsMobile();
1297
- const [view, setView] = useState<'table' | 'cards'>('table');
1298
- const effectiveView = isMobile ? 'cards' : view;
1299
-
1300
- const [search, setSearch] = useState('');
1301
- const [activeFilters, setActiveFilters] = useState<Record<string, string[]>>({});
1302
- const [sort, setSort] = useState<{ key: string; dir: 'asc' | 'desc' } | null>(null);
1303
- const [page, setPage] = useState(1);
1304
- const [pageSize, setPageSize] = useState(25);
1305
-
1306
- const [showCreate, setShowCreate] = useState(false);
1307
- const [createForm, setCreateForm] = useState<UserFormState>(initForm());
1308
- const [editingId, setEditingId] = useState<string | null>(null);
1309
- const [editForm, setEditForm] = useState<UserFormState>(initForm());
1310
- const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
1311
-
1312
- const allUsers: UserRow[] = (data as { data: UserRow[] } | undefined)?.data ?? [];
1313
-
1314
- const filtered = useMemo(() => {
1315
- let result = [...allUsers];
1316
- if (search) {
1317
- const q = search.toLowerCase();
1318
- result = result.filter(u => u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q));
1319
- }
1320
- if (activeFilters.role?.length) result = result.filter(u => activeFilters.role.includes(u.role));
1321
- if (sort) {
1322
- result.sort((a, b) => {
1323
- const aVal = String(a[sort.key as keyof UserRow] ?? '');
1324
- const bVal = String(b[sort.key as keyof UserRow] ?? '');
1325
- return sort.dir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
1326
- });
1327
- }
1328
- return result;
1329
- }, [allUsers, search, activeFilters, sort]);
1330
-
1331
- useEffect(() => { setPage(1); }, [search, activeFilters, sort]);
1332
- const paginated = filtered.slice((page - 1) * pageSize, page * pageSize);
1333
-
1334
- const filterConfigs: FilterConfig[] = [
1335
- {
1336
- key: 'role', label: 'Role', options: [
1337
- { value: 'owner', label: 'Owner' },
1338
- { value: 'admin', label: 'Admin' },
1339
- { value: 'member', label: 'Member' },
1340
- ],
1341
- },
1342
- ];
1343
-
1344
- const sortOptions: SortOption[] = [
1345
- { key: 'name', label: 'Name' },
1346
- { key: 'email', label: 'Email' },
1347
- { key: 'role', label: 'Role' },
1348
- { key: 'created_at', label: 'Joined' },
1349
- ];
1350
-
1351
- const handleFilterChange = (key: string, values: string[]) => {
1352
- setActiveFilters(prev => { const next = { ...prev }; if (values.length === 0) delete next[key]; else next[key] = values; return next; });
1353
- };
1354
- const handleSortChange = (key: string) => {
1355
- setSort(prev => prev?.key === key ? { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'asc' });
1356
- };
1357
-
1358
- const touchAll = (form: UserFormState): UserFormState => ({
1359
- ...form, touched: { name: true, email: true, password: true },
1360
- });
1361
-
1362
- const handleCreate = async () => {
1363
- const f = touchAll(createForm);
1364
- setCreateForm(f);
1365
- if (!f.name.trim() || !isValidEmail(f.email) || !isStrongPassword(f.password)) return;
1366
- try {
1367
- await createUser.mutateAsync({ name: f.name.trim(), email: f.email.trim(), password: f.password, role: f.role });
1368
- setShowCreate(false);
1369
- setCreateForm(initForm());
1370
- toast({ title: 'User created' });
1371
- } catch (err) {
1372
- toast({ title: 'Error', description: err instanceof Error ? err.message : 'Failed to create user', variant: 'destructive' });
1373
- }
1374
- };
1375
-
1376
- const startEdit = (u: UserRow) => {
1377
- setEditingId(u.id);
1378
- setEditForm(initForm({ name: u.name, email: u.email, role: u.role as Role }));
1379
- };
1380
-
1381
- const handleUpdate = async () => {
1382
- const f = touchAll(editForm);
1383
- setEditForm(f);
1384
- if (!f.name.trim() || !isValidEmail(f.email)) return;
1385
- if (f.password && !isStrongPassword(f.password)) return;
1386
- try {
1387
- await updateUser.mutateAsync({
1388
- id: editingId!,
1389
- name: f.name.trim(),
1390
- email: f.email.trim(),
1391
- role: f.role,
1392
- ...(f.password ? { password: f.password } : {}),
1393
- });
1394
- setEditingId(null);
1395
- toast({ title: 'User updated' });
1396
- } catch (err) {
1397
- toast({ title: 'Error', description: err instanceof Error ? err.message : 'Failed to update user', variant: 'destructive' });
1398
- }
1399
- };
1400
-
1401
- const handleDelete = async (id: string) => {
1402
- try {
1403
- await deleteUser.mutateAsync(id);
1404
- setConfirmDeleteId(null);
1405
- toast({ title: 'User deleted' });
1406
- } catch (err) {
1407
- toast({ title: 'Error', description: err instanceof Error ? err.message : 'Failed to delete user', variant: 'destructive' });
1408
- }
1409
- };
1410
-
1411
- const rolePillCls: Record<string, string> = {
1412
- owner: 'bg-accent/15 text-accent border-accent/30',
1413
- admin: 'bg-primary/15 text-primary border-primary/30',
1414
- member: 'bg-muted text-muted-foreground border-border',
1415
- };
1416
-
1417
- const SortHeader = ({ label, sortKey }: { label: string; sortKey: string }) => (
1418
- <th
1419
- onClick={() => handleSortChange(sortKey)}
1420
- className="text-left px-4 py-3 text-xs font-display font-semibold text-muted-foreground cursor-pointer hover:text-foreground transition-colors select-none"
1421
- >
1422
- <span className="inline-flex items-center gap-1">
1423
- {label}
1424
- {sort?.key === sortKey ? (sort.dir === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />) : null}
1425
- </span>
1426
- </th>
1427
- );
1428
-
1429
- const InlineEditActions = ({ onSave, onCancel, isPending }: { onSave: () => void; onCancel: () => void; isPending: boolean }) => (
1430
- <div className="flex gap-2 pt-2">
1431
- <button onClick={onSave} disabled={isPending}
1432
- className="px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90 disabled:opacity-40 transition-colors">
1433
- {isPending ? 'Saving…' : 'Save Changes'}
1434
- </button>
1435
- <button onClick={onCancel}
1436
- className="px-3 py-1.5 rounded-lg bg-muted text-muted-foreground text-xs font-semibold hover:bg-muted/80 transition-colors">
1437
- Cancel
1438
- </button>
1439
- </div>
1440
- );
1441
-
1442
- return (
1443
- <div className="-mx-6 -my-6 flex flex-col">
1444
- {/* Page header */}
1445
- <div className="flex items-start justify-between px-6 pt-6 pb-3">
1446
- <div>
1447
- <h2 className="font-display font-bold text-lg text-foreground">Team Members</h2>
1448
- <p className="text-sm text-muted-foreground mt-0.5">Manage who has access to your CRMy workspace.</p>
1449
- </div>
1450
- <div className="hidden md:flex items-center gap-1 bg-muted rounded-xl p-0.5 mt-0.5">
1451
- <button
1452
- onClick={() => setView('table')}
1453
- className={`p-1.5 rounded-lg text-sm transition-all ${view === 'table' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}
1454
- >
1455
- <List className="w-4 h-4" />
1456
- </button>
1457
- <button
1458
- onClick={() => setView('cards')}
1459
- className={`p-1.5 rounded-lg text-sm transition-all ${view === 'cards' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}
1460
- >
1461
- <LayoutGrid className="w-4 h-4" />
1462
- </button>
1463
- </div>
1464
- </div>
1465
-
1466
- {/* Toolbar */}
1467
- <ListToolbar
1468
- searchValue={search} onSearchChange={setSearch} searchPlaceholder="Search users..."
1469
- filters={filterConfigs} activeFilters={activeFilters} onFilterChange={handleFilterChange}
1470
- onClearFilters={() => setActiveFilters({})} sortOptions={sortOptions} currentSort={sort}
1471
- onSortChange={handleSortChange}
1472
- onAdd={() => { setShowCreate(true); setEditingId(null); setConfirmDeleteId(null); }}
1473
- addLabel="New User" entityType="users"
1474
- />
1475
-
1476
- <div className="px-4 md:px-6 pb-8 space-y-3 mt-1">
1477
- {/* Create form */}
1478
- {showCreate && (
1479
- <div className="p-4 rounded-xl border border-border bg-muted/30 space-y-4">
1480
- <p className="text-xs font-display font-semibold text-muted-foreground uppercase tracking-wider">New User</p>
1481
- <UserForm
1482
- form={createForm} onChange={p => setCreateForm(f => ({ ...f, ...p }))}
1483
- onTouch={field => setCreateForm(f => ({ ...f, touched: { ...f.touched, [field]: true } }))}
1484
- isEdit={false} currentUserRole={currentUserRole}
1485
- />
1486
- <div className="flex gap-2 pt-1">
1487
- <button onClick={handleCreate} disabled={createUser.isPending}
1488
- className="px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90 disabled:opacity-40 transition-colors">
1489
- {createUser.isPending ? 'Creating...' : 'Create User'}
1490
- </button>
1491
- <button onClick={() => { setShowCreate(false); setCreateForm(initForm()); }}
1492
- className="px-3 py-1.5 rounded-lg bg-muted text-muted-foreground text-xs font-semibold hover:bg-muted/80 transition-colors">
1493
- Cancel
1494
- </button>
1495
- </div>
1496
- </div>
1497
- )}
1498
-
1499
- {/* Content */}
1500
- {isLoading ? (
1501
- <div className="space-y-2">
1502
- {[...Array(4)].map((_, i) => <div key={i} className="h-14 bg-muted/50 rounded-xl animate-pulse" />)}
1503
- </div>
1504
- ) : filtered.length === 0 ? (
1505
- <div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
1506
- <Users className="w-8 h-8 mb-3 opacity-30" />
1507
- <p className="text-sm">No users found.</p>
1508
- {(search || Object.keys(activeFilters).length > 0) && (
1509
- <button
1510
- onClick={() => { setSearch(''); setActiveFilters({}); }}
1511
- className="mt-2 text-xs text-primary font-semibold hover:underline"
1512
- >
1513
- Clear filters
1514
- </button>
1515
- )}
1516
- </div>
1517
- ) : effectiveView === 'table' ? (
1518
- /* ── Table view ── */
1519
- <div className="bg-card border border-border rounded-2xl overflow-hidden shadow-sm">
1520
- <div className="overflow-x-auto">
1521
- <table className="w-full text-sm">
1522
- <thead>
1523
- <tr className="border-b border-border bg-surface-sunken/50">
1524
- <SortHeader label="Name" sortKey="name" />
1525
- <SortHeader label="Email" sortKey="email" />
1526
- <SortHeader label="Role" sortKey="role" />
1527
- <SortHeader label="Joined" sortKey="created_at" />
1528
- <th className="px-2 py-3 w-20" />
1529
- </tr>
1530
- </thead>
1531
- <tbody>
1532
- {paginated.map((u, i) => (
1533
- <React.Fragment key={u.id}>
1534
- {editingId === u.id ? (
1535
- <tr>
1536
- <td colSpan={5} className="p-4 bg-muted/20 border-b border-border last:border-0">
1537
- <p className="text-xs font-display font-semibold text-muted-foreground uppercase tracking-wider mb-3">Edit User</p>
1538
- <UserForm
1539
- form={editForm} onChange={p => setEditForm(f => ({ ...f, ...p }))}
1540
- onTouch={field => setEditForm(f => ({ ...f, touched: { ...f.touched, [field]: true } }))}
1541
- isEdit={true} currentUserRole={currentUserRole}
1542
- />
1543
- <InlineEditActions onSave={handleUpdate} onCancel={() => setEditingId(null)} isPending={updateUser.isPending} />
1544
- </td>
1545
- </tr>
1546
- ) : confirmDeleteId === u.id ? (
1547
- <tr className={`border-b border-border last:border-0 ${i % 2 === 1 ? 'bg-surface-sunken/30' : ''}`}>
1548
- <td colSpan={5} className="px-4 py-3">
1549
- <div className="flex items-center gap-3 flex-wrap">
1550
- <p className="text-sm text-foreground flex-1">Delete <strong>{u.name}</strong>? This cannot be undone.</p>
1551
- <button onClick={() => handleDelete(u.id)} disabled={deleteUser.isPending}
1552
- className="px-3 py-1.5 rounded-lg bg-destructive text-destructive-foreground text-xs font-semibold hover:bg-destructive/90 disabled:opacity-40 transition-colors">
1553
- {deleteUser.isPending ? 'Deleting...' : 'Confirm Delete'}
1554
- </button>
1555
- <button onClick={() => setConfirmDeleteId(null)}
1556
- className="px-3 py-1.5 rounded-lg bg-muted text-muted-foreground text-xs font-semibold hover:bg-muted/80 transition-colors">
1557
- Cancel
1558
- </button>
1559
- </div>
1560
- </td>
1561
- </tr>
1562
- ) : (
1563
- <tr className={`border-b border-border last:border-0 hover:bg-primary/5 transition-colors group ${i % 2 === 1 ? 'bg-surface-sunken/30' : ''}`}>
1564
- <td className="px-4 py-3">
1565
- <div className="flex items-center gap-3">
1566
- <UserAvatar name={u.name} />
1567
- <div>
1568
- <div className="flex items-center gap-1.5">
1569
- <span className="font-semibold text-foreground">{u.name}</span>
1570
- {u.id === currentUser?.id && (
1571
- <span className="text-[10px] font-mono bg-muted text-muted-foreground px-1.5 py-0.5 rounded">you</span>
1572
- )}
1573
- </div>
1574
- </div>
1575
- </div>
1576
- </td>
1577
- <td className="px-4 py-3 text-muted-foreground">{u.email}</td>
1578
- <td className="px-4 py-3">
1579
- <span className={`text-[11px] font-semibold px-2 py-0.5 rounded-full border ${rolePillCls[u.role] ?? rolePillCls.member}`}>
1580
- {roleLabels[u.role as Role] ?? u.role}
1581
- </span>
1582
- </td>
1583
- <td className="px-4 py-3 text-xs text-muted-foreground">
1584
- {u.created_at ? new Date(u.created_at).toLocaleDateString() : '—'}
1585
- </td>
1586
- <td className="px-2 py-3">
1587
- <div className="flex items-center gap-1 justify-end opacity-0 group-hover:opacity-100 transition-all">
1588
- <button
1589
- onClick={() => { startEdit(u); setConfirmDeleteId(null); }}
1590
- className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
1591
- >
1592
- <Pencil className="w-3.5 h-3.5" />
1593
- </button>
1594
- <button
1595
- disabled={u.id === currentUser?.id}
1596
- onClick={() => { setConfirmDeleteId(u.id); setEditingId(null); }}
1597
- className="p-1.5 rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
1598
- >
1599
- <Trash2 className="w-3.5 h-3.5" />
1600
- </button>
1601
- </div>
1602
- </td>
1603
- </tr>
1604
- )}
1605
- </React.Fragment>
1606
- ))}
1607
- </tbody>
1608
- </table>
1609
- </div>
1610
- <PaginationBar page={page} pageSize={pageSize} total={filtered.length} onPageChange={setPage} onPageSizeChange={setPageSize} className="px-4" />
1611
- </div>
1612
- ) : (
1613
- /* ── Card view ── */
1614
- <>
1615
- <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
1616
- {paginated.map((u, i) => (
1617
- editingId === u.id ? (
1618
- <motion.div
1619
- key={u.id} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }}
1620
- className="col-span-full bg-card border border-border rounded-2xl p-4 space-y-4"
1621
- >
1622
- <p className="text-xs font-display font-semibold text-muted-foreground uppercase tracking-wider">Edit User</p>
1623
- <UserForm
1624
- form={editForm} onChange={p => setEditForm(f => ({ ...f, ...p }))}
1625
- onTouch={field => setEditForm(f => ({ ...f, touched: { ...f.touched, [field]: true } }))}
1626
- isEdit={true} currentUserRole={currentUserRole}
1627
- />
1628
- <InlineEditActions onSave={handleUpdate} onCancel={() => setEditingId(null)} isPending={updateUser.isPending} />
1629
- </motion.div>
1630
- ) : confirmDeleteId === u.id ? (
1631
- <motion.div
1632
- key={u.id} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }}
1633
- className="bg-card border border-destructive/30 rounded-2xl p-4"
1634
- >
1635
- <p className="text-sm text-foreground mb-3">Delete <strong>{u.name}</strong>? This cannot be undone.</p>
1636
- <div className="flex gap-2">
1637
- <button onClick={() => handleDelete(u.id)} disabled={deleteUser.isPending}
1638
- className="px-3 py-1.5 rounded-lg bg-destructive text-destructive-foreground text-xs font-semibold hover:bg-destructive/90 disabled:opacity-40 transition-colors">
1639
- {deleteUser.isPending ? 'Deleting...' : 'Confirm Delete'}
1640
- </button>
1641
- <button onClick={() => setConfirmDeleteId(null)}
1642
- className="px-3 py-1.5 rounded-lg bg-muted text-muted-foreground text-xs font-semibold hover:bg-muted/80 transition-colors">
1643
- Cancel
1644
- </button>
1645
- </div>
1646
- </motion.div>
1647
- ) : (
1648
- <motion.div
1649
- key={u.id} initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.02 }}
1650
- className="bg-card border border-border rounded-2xl p-4 hover:shadow-md hover:border-primary/20 transition-all group relative"
1651
- >
1652
- <div className="flex items-start justify-between gap-2">
1653
- <div className="flex items-center gap-3">
1654
- <UserAvatar name={u.name} size="lg" />
1655
- <div>
1656
- <div className="flex items-center gap-1.5 flex-wrap">
1657
- <p className="font-display font-bold text-foreground">{u.name}</p>
1658
- {u.id === currentUser?.id && (
1659
- <span className="text-[10px] font-mono bg-muted text-muted-foreground px-1.5 py-0.5 rounded">you</span>
1660
- )}
1661
- </div>
1662
- <p className="text-xs text-muted-foreground">{u.email}</p>
1663
- </div>
1664
- </div>
1665
- <div className="flex items-center gap-1 flex-shrink-0 md:opacity-0 md:group-hover:opacity-100 transition-all">
1666
- <button
1667
- onClick={() => { startEdit(u); setConfirmDeleteId(null); }}
1668
- className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
1669
- >
1670
- <Pencil className="w-3.5 h-3.5" />
1671
- </button>
1672
- <button
1673
- disabled={u.id === currentUser?.id}
1674
- onClick={() => { setConfirmDeleteId(u.id); setEditingId(null); }}
1675
- className="p-1.5 rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
1676
- >
1677
- <Trash2 className="w-3.5 h-3.5" />
1678
- </button>
1679
- </div>
1680
- </div>
1681
- <div className="mt-3 flex items-center justify-between">
1682
- <span className={`text-[11px] font-semibold px-2 py-0.5 rounded-full border ${rolePillCls[u.role] ?? rolePillCls.member}`}>
1683
- {roleLabels[u.role as Role] ?? u.role}
1684
- </span>
1685
- {u.created_at && (
1686
- <span className="text-[10px] text-muted-foreground">
1687
- {new Date(u.created_at).toLocaleDateString()}
1688
- </span>
1689
- )}
1690
- </div>
1691
- </motion.div>
1692
- )
1693
- ))}
1694
- </div>
1695
- <PaginationBar page={page} pageSize={pageSize} total={filtered.length} onPageChange={setPage} onPageSizeChange={setPageSize} />
1696
- </>
1697
- )}
1698
- </div>
1699
- </div>
1700
- );
1701
- }
1702
-
1703
- function DatabaseSettings() {
1704
- const { data, isLoading } = useDbConfig();
1705
- const testConfig = useTestDbConfig();
1706
- const saveConfig = useSaveDbConfig();
1707
- const [editing, setEditing] = useState(false);
1708
- const [connStr, setConnStr] = useState('');
1709
- const [testResult, setTestResult] = useState<'idle' | 'testing' | 'ok' | 'fail'>('idle');
1710
- const [testError, setTestError] = useState('');
1711
- const [saveSuccess, setSaveSuccess] = useState('');
1712
-
1713
- const dbInfo = data as { host: string; port: string; database: string; user: string; ssl: string | null } | undefined;
1714
-
1715
- const handleTest = async () => {
1716
- setTestResult('testing');
1717
- setTestError('');
1718
- setSaveSuccess('');
1719
- try {
1720
- await testConfig.mutateAsync(connStr);
1721
- setTestResult('ok');
1722
- } catch (err) {
1723
- setTestResult('fail');
1724
- setTestError(err instanceof Error ? err.message : 'Connection failed');
1725
- }
1726
- };
1727
-
1728
- const handleSave = async () => {
1729
- try {
1730
- const result = await saveConfig.mutateAsync(connStr) as { message: string };
1731
- setSaveSuccess(result.message);
1732
- setEditing(false);
1733
- setConnStr('');
1734
- setTestResult('idle');
1735
- toast({ title: 'Database config saved', description: result.message });
1736
- } catch (err) {
1737
- toast({ title: 'Error', description: err instanceof Error ? err.message : 'Failed to save', variant: 'destructive' });
1738
- }
1739
- };
1740
-
1741
- return (
1742
- <div>
1743
- <h2 className="font-display font-bold text-lg text-foreground mb-2">Database Connection</h2>
1744
- <p className="text-sm text-muted-foreground mb-6">
1745
- View and update the PostgreSQL database connection. Changes are saved to <code className="text-xs font-mono bg-muted px-1 py-0.5 rounded">.env.db</code> and take effect after a server restart.
1746
- </p>
1747
-
1748
- <div className="space-y-4 max-w-lg">
1749
- {isLoading ? (
1750
- <div className="space-y-2">{[...Array(3)].map((_, i) => <div key={i} className="h-10 bg-muted/50 rounded-lg animate-pulse" />)}</div>
1751
- ) : (
1752
- <div className="p-4 rounded-xl border border-border bg-card space-y-3">
1753
- <p className="text-xs font-display font-semibold text-muted-foreground uppercase tracking-wider mb-2">Current Connection</p>
1754
- {[
1755
- { label: 'Host', value: dbInfo?.host || '—' },
1756
- { label: 'Port', value: dbInfo?.port || '—' },
1757
- { label: 'Database', value: dbInfo?.database || '—' },
1758
- { label: 'User', value: dbInfo?.user || '—' },
1759
- { label: 'SSL', value: dbInfo?.ssl || 'default' },
1760
- ].map((row) => (
1761
- <div key={row.label} className="flex items-center gap-3">
1762
- <span className="text-xs font-medium text-muted-foreground w-20 flex-shrink-0">{row.label}</span>
1763
- <code className="text-sm font-mono text-foreground">{row.value}</code>
1764
- </div>
1765
- ))}
1766
- </div>
1767
- )}
1768
-
1769
- {saveSuccess && (
1770
- <div className="flex items-start gap-2 p-3 rounded-xl border border-success/30 bg-success/5 text-sm text-success">
1771
- <CheckCircle2 className="w-4 h-4 flex-shrink-0 mt-0.5" />
1772
- <span>{saveSuccess}</span>
1773
- </div>
1774
- )}
1775
-
1776
- {!editing ? (
1777
- <button onClick={() => { setEditing(true); setSaveSuccess(''); setTestResult('idle'); }}
1778
- className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90 transition-colors">
1779
- <Database className="w-3.5 h-3.5" /> Edit Connection
1780
- </button>
1781
- ) : (
1782
- <div className="space-y-3 p-4 rounded-xl border border-border bg-muted/30">
1783
- <p className="text-xs font-display font-semibold text-muted-foreground uppercase tracking-wider">New Connection String</p>
1784
- <input
1785
- value={connStr}
1786
- onChange={(e) => { setConnStr(e.target.value); setTestResult('idle'); setTestError(''); }}
1787
- placeholder="postgresql://user:password@host:5432/dbname"
1788
- className="w-full h-9 px-3 rounded-lg border border-border bg-background text-sm text-foreground placeholder:text-muted-foreground font-mono outline-none focus:ring-1 focus:ring-ring"
1789
- />
1790
-
1791
- {testResult === 'ok' && (
1792
- <div className="flex items-center gap-1.5 text-xs text-success">
1793
- <CheckCircle2 className="w-3.5 h-3.5" /> Connection successful
1794
- </div>
1795
- )}
1796
- {testResult === 'fail' && (
1797
- <div className="flex items-start gap-1.5 text-xs text-destructive">
1798
- <XCircle className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" /> {testError}
1799
- </div>
1800
- )}
1801
-
1802
- <div className="flex items-center gap-2 flex-wrap">
1803
- <button onClick={handleTest}
1804
- disabled={!connStr.trim() || testConfig.isPending}
1805
- className="px-3 py-1.5 rounded-lg border border-border text-xs font-semibold text-muted-foreground hover:text-foreground hover:border-foreground/30 disabled:opacity-40 transition-colors">
1806
- {testConfig.isPending ? 'Testing...' : 'Test Connection'}
1807
- </button>
1808
- <button onClick={handleSave}
1809
- disabled={!connStr.trim() || testResult !== 'ok' || saveConfig.isPending}
1810
- className="px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90 disabled:opacity-40 transition-colors">
1811
- {saveConfig.isPending ? 'Saving...' : 'Save'}
1812
- </button>
1813
- <button onClick={() => { setEditing(false); setConnStr(''); setTestResult('idle'); setTestError(''); }}
1814
- className="px-3 py-1.5 rounded-lg bg-muted text-muted-foreground text-xs font-semibold hover:bg-muted/80 transition-colors">
1815
- Cancel
1816
- </button>
1817
- </div>
1818
- <p className="text-xs text-muted-foreground">Test the connection before saving. Save is only enabled after a successful test.</p>
1819
- </div>
1820
- )}
1821
- </div>
1822
- </div>
1823
- );
1824
- }
1825
-
1826
- export default function Settings() {
1827
- const location = useLocation();
1828
- const user = getUser();
1829
- const userRole = (user?.role ?? 'member') as NavRole;
1830
- const visibleNav = settingsNavConfig.filter(item => item.roles.includes(userRole));
1831
- const { enabled: agentEnabled } = useAgentSettings();
1832
-
1833
- return (
1834
- <div className="flex flex-col h-full">
1835
- <TopBar title="Settings" />
1836
-
1837
- <div className="md:hidden flex gap-1 overflow-x-auto no-scrollbar px-4 pt-3 pb-1 border-b border-border">
1838
- {visibleNav.map((item) => {
1839
- const active = item.path === '/settings' ? location.pathname === '/settings' : location.pathname.startsWith(item.path);
1840
- return (
1841
- <Link key={item.path} to={item.path}
1842
- className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold whitespace-nowrap transition-colors ${active ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'}`}>
1843
- <item.icon className="w-3.5 h-3.5" />
1844
- {item.label}
1845
- {item.path === '/settings/agent' && (
1846
- <span className={`w-2 h-2 rounded-full ${agentEnabled ? 'bg-amber-500' : 'bg-muted-foreground/40'}`} />
1847
- )}
1848
- </Link>
1849
- );
1850
- })}
1851
- </div>
1852
-
1853
- <div className="flex-1 flex overflow-hidden">
1854
- <nav className="hidden md:flex flex-col w-48 border-r border-border bg-muted p-2 gap-0.5">
1855
- {visibleNav.map((item) => {
1856
- const active = item.path === '/settings' ? location.pathname === '/settings' : location.pathname.startsWith(item.path);
1857
- return (
1858
- <Link key={item.path} to={item.path}
1859
- className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors ${active ? 'bg-primary/15 text-primary' : 'text-foreground/60 hover:bg-muted hover:text-foreground'}`}>
1860
- <item.icon className="w-4 h-4" />
1861
- {item.label}
1862
- {item.path === '/settings/agent' && (
1863
- <span className={`ml-auto w-2 h-2 rounded-full ${agentEnabled ? 'bg-amber-500' : 'bg-muted-foreground/40'}`} />
1864
- )}
1865
- </Link>
1866
- );
1867
- })}
1868
- </nav>
1869
- <div className="flex-1 overflow-y-auto p-6 pb-20 md:pb-6">
1870
- <Routes>
1871
- <Route index element={<ProfileSettings />} />
1872
- <Route path="appearance" element={<AppearanceSettings />} />
1873
- <Route path="api-keys" element={<ApiKeysSettings />} />
1874
- <Route path="webhooks" element={<RequireRole roles={['admin', 'owner']}><WebhooksSettings /></RequireRole>} />
1875
- <Route path="custom-fields" element={<RequireRole roles={['admin', 'owner']}><CustomFieldsSettings /></RequireRole>} />
1876
- <Route path="actors" element={<RequireRole roles={['admin', 'owner']}><ActorsSettings /></RequireRole>} />
1877
- <Route path="agent" element={<RequireRole roles={['admin', 'owner']}><AgentSettings /></RequireRole>} />
1878
- <Route path="database" element={<RequireRole roles={['admin', 'owner']}><DatabaseSettings /></RequireRole>} />
1879
- </Routes>
1880
- </div>
1881
- </div>
1882
- </div>
1883
- );
1884
- }