@mdguggenbichler/slugbase-core 0.0.4 → 0.0.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 (120) hide show
  1. package/frontend/index.tsx +3 -3
  2. package/frontend/public/favicon.svg +1 -0
  3. package/frontend/public/slugbase_icon_blue.svg +1 -0
  4. package/frontend/public/slugbase_icon_white.png +0 -0
  5. package/frontend/public/slugbase_icon_white.svg +1 -0
  6. package/frontend/src/App.tsx +179 -0
  7. package/frontend/src/api/client.ts +134 -0
  8. package/frontend/src/components/AppSidebar.tsx +214 -0
  9. package/frontend/src/components/EmptyState.tsx +33 -0
  10. package/frontend/src/components/Favicon.tsx +76 -0
  11. package/frontend/src/components/FilterChips.tsx +60 -0
  12. package/frontend/src/components/FolderIcon.tsx +207 -0
  13. package/frontend/src/components/GlobalSearch.tsx +275 -0
  14. package/frontend/src/components/Layout.tsx +60 -0
  15. package/frontend/src/components/PageHeader.tsx +31 -0
  16. package/frontend/src/components/ScopeSegmentedControl.tsx +42 -0
  17. package/frontend/src/components/SentryDebug.tsx +32 -0
  18. package/frontend/src/components/StatCard.tsx +66 -0
  19. package/frontend/src/components/TopBar.tsx +63 -0
  20. package/frontend/src/components/UserDropdown.tsx +86 -0
  21. package/frontend/src/components/admin/AdminAI.tsx +207 -0
  22. package/frontend/src/components/admin/AdminOIDCProviders.tsx +183 -0
  23. package/frontend/src/components/admin/AdminSettings.tsx +413 -0
  24. package/frontend/src/components/admin/AdminTeams.tsx +177 -0
  25. package/frontend/src/components/admin/AdminUsers.tsx +225 -0
  26. package/frontend/src/components/bookmarks/BookmarkCard.tsx +312 -0
  27. package/frontend/src/components/bookmarks/BookmarkListItem.tsx +159 -0
  28. package/frontend/src/components/bookmarks/BookmarkTableView.tsx +419 -0
  29. package/frontend/src/components/bookmarks/BulkActionModals.tsx +162 -0
  30. package/frontend/src/components/bookmarks/FilterChips.tsx +5 -0
  31. package/frontend/src/components/modals/BookmarkModal.tsx +493 -0
  32. package/frontend/src/components/modals/FolderModal.tsx +306 -0
  33. package/frontend/src/components/modals/ImportModal.tsx +232 -0
  34. package/frontend/src/components/modals/OIDCProviderModal.tsx +284 -0
  35. package/frontend/src/components/modals/SharingModal.tsx +96 -0
  36. package/frontend/src/components/modals/TagModal.tsx +101 -0
  37. package/frontend/src/components/modals/TeamAssignmentModal.tsx +354 -0
  38. package/frontend/src/components/modals/TeamModal.tsx +117 -0
  39. package/frontend/src/components/modals/UserModal.tsx +225 -0
  40. package/frontend/src/components/profile/CreateTokenModal.tsx +172 -0
  41. package/frontend/src/components/sharing/ShareResourceDialog.tsx +422 -0
  42. package/frontend/src/components/ui/Autocomplete.tsx +155 -0
  43. package/frontend/src/components/ui/Button.tsx +68 -0
  44. package/frontend/src/components/ui/ConfirmDialog.tsx +79 -0
  45. package/frontend/src/components/ui/FormFieldWrapper.tsx +36 -0
  46. package/frontend/src/components/ui/ModalFooterActions.tsx +49 -0
  47. package/frontend/src/components/ui/ModalSection.tsx +34 -0
  48. package/frontend/src/components/ui/PageLoadingSkeleton.tsx +24 -0
  49. package/frontend/src/components/ui/Select.tsx +61 -0
  50. package/frontend/src/components/ui/SharingField.tsx +298 -0
  51. package/frontend/src/components/ui/Toast.tsx +47 -0
  52. package/frontend/src/components/ui/Tooltip.tsx +21 -0
  53. package/frontend/src/components/ui/alert-dialog.tsx +139 -0
  54. package/frontend/src/components/ui/badge.tsx +36 -0
  55. package/frontend/src/components/ui/button-base.tsx +57 -0
  56. package/frontend/src/components/ui/card.tsx +76 -0
  57. package/frontend/src/components/ui/command.tsx +161 -0
  58. package/frontend/src/components/ui/dialog.tsx +120 -0
  59. package/frontend/src/components/ui/dropdown-menu.tsx +199 -0
  60. package/frontend/src/components/ui/input.tsx +22 -0
  61. package/frontend/src/components/ui/label.tsx +24 -0
  62. package/frontend/src/components/ui/popover.tsx +33 -0
  63. package/frontend/src/components/ui/progress.tsx +26 -0
  64. package/frontend/src/components/ui/scroll-area.tsx +48 -0
  65. package/frontend/src/components/ui/select-base.tsx +159 -0
  66. package/frontend/src/components/ui/separator.tsx +29 -0
  67. package/frontend/src/components/ui/sheet.tsx +140 -0
  68. package/frontend/src/components/ui/sidebar.tsx +783 -0
  69. package/frontend/src/components/ui/skeleton.tsx +15 -0
  70. package/frontend/src/components/ui/sonner.tsx +46 -0
  71. package/frontend/src/components/ui/switch.tsx +28 -0
  72. package/frontend/src/components/ui/table.tsx +120 -0
  73. package/frontend/src/components/ui/tooltip-base.tsx +30 -0
  74. package/frontend/src/config/api.ts +16 -0
  75. package/frontend/src/config/mode.ts +6 -0
  76. package/frontend/src/contexts/AppConfigContext.tsx +39 -0
  77. package/frontend/src/contexts/AuthContext.tsx +137 -0
  78. package/frontend/src/contexts/SearchCommandContext.tsx +28 -0
  79. package/frontend/src/hooks/use-mobile.tsx +19 -0
  80. package/frontend/src/hooks/useConfirmDialog.ts +63 -0
  81. package/frontend/src/hooks/useMarketingTheme.ts +47 -0
  82. package/frontend/src/i18n.ts +39 -0
  83. package/frontend/src/index.css +117 -0
  84. package/frontend/src/instrument.ts +20 -0
  85. package/frontend/src/lib/utils.ts +6 -0
  86. package/frontend/src/locales/de.json +899 -0
  87. package/frontend/src/locales/en.json +937 -0
  88. package/frontend/src/locales/es.json +884 -0
  89. package/frontend/src/locales/fr.json +550 -0
  90. package/frontend/src/locales/it.json +535 -0
  91. package/frontend/src/locales/ja.json +535 -0
  92. package/frontend/src/locales/nl.json +550 -0
  93. package/frontend/src/locales/pl.json +535 -0
  94. package/frontend/src/locales/pt.json +535 -0
  95. package/frontend/src/locales/ru.json +535 -0
  96. package/frontend/src/locales/zh.json +535 -0
  97. package/frontend/src/main.tsx +44 -0
  98. package/frontend/src/pages/Bookmarks.tsx +1004 -0
  99. package/frontend/src/pages/Dashboard.tsx +427 -0
  100. package/frontend/src/pages/Folders.tsx +578 -0
  101. package/frontend/src/pages/GoPreferences.tsx +134 -0
  102. package/frontend/src/pages/Login.tsx +196 -0
  103. package/frontend/src/pages/PasswordReset.tsx +242 -0
  104. package/frontend/src/pages/Profile.tsx +593 -0
  105. package/frontend/src/pages/SearchEngineGuide.tsx +135 -0
  106. package/frontend/src/pages/Setup.tsx +210 -0
  107. package/frontend/src/pages/Signup.tsx +199 -0
  108. package/frontend/src/pages/Tags.tsx +421 -0
  109. package/frontend/src/pages/VerifyEmail.tsx +254 -0
  110. package/frontend/src/pages/admin/AdminAIPage.tsx +5 -0
  111. package/frontend/src/pages/admin/AdminLayout.tsx +40 -0
  112. package/frontend/src/pages/admin/AdminMembersPage.tsx +5 -0
  113. package/frontend/src/pages/admin/AdminOIDCPage.tsx +5 -0
  114. package/frontend/src/pages/admin/AdminSettingsPage.tsx +5 -0
  115. package/frontend/src/pages/admin/AdminTeamsPage.tsx +5 -0
  116. package/frontend/src/utils/favicon.ts +36 -0
  117. package/frontend/src/utils/formatRelativeTime.ts +37 -0
  118. package/frontend/src/utils/safeHref.ts +31 -0
  119. package/frontend/src/vite-env.d.ts +10 -0
  120. package/package.json +9 -1
@@ -0,0 +1,354 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import api from '../../api/client';
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ DialogFooter,
10
+ } from '../ui/dialog';
11
+ import { Separator } from '../ui/separator';
12
+ import { Input } from '../ui/input';
13
+ import Button from '../ui/Button';
14
+ import { UserPlus, X, Users, Search } from 'lucide-react';
15
+
16
+ interface Team {
17
+ id: string;
18
+ name: string;
19
+ description?: string;
20
+ }
21
+
22
+ interface User {
23
+ id: string;
24
+ email: string;
25
+ name: string;
26
+ is_admin: boolean;
27
+ }
28
+
29
+ interface TeamAssignmentModalProps {
30
+ mode: 'user' | 'team';
31
+ userId?: string;
32
+ userName?: string;
33
+ teamId?: string;
34
+ teamName?: string;
35
+ isOpen: boolean;
36
+ onClose: () => void;
37
+ onSuccess: () => void;
38
+ }
39
+
40
+ export default function TeamAssignmentModal({
41
+ mode,
42
+ userId,
43
+ userName,
44
+ teamId,
45
+ teamName,
46
+ isOpen,
47
+ onClose,
48
+ onSuccess,
49
+ }: TeamAssignmentModalProps) {
50
+ const { t } = useTranslation();
51
+ const [allTeams, setAllTeams] = useState<Team[]>([]);
52
+ const [allUsers, setAllUsers] = useState<User[]>([]);
53
+ const [userTeams, setUserTeams] = useState<Team[]>([]);
54
+ const [teamMembers, setTeamMembers] = useState<User[]>([]);
55
+ const [loading, setLoading] = useState(true);
56
+ const [saving, setSaving] = useState(false);
57
+ const [searchQuery, setSearchQuery] = useState('');
58
+
59
+ useEffect(() => {
60
+ if (isOpen) {
61
+ setSearchQuery('');
62
+ loadData();
63
+ }
64
+ }, [isOpen, mode, userId, teamId]);
65
+
66
+ async function loadData() {
67
+ try {
68
+ setLoading(true);
69
+ if (mode === 'user' && userId) {
70
+ const [teamsRes, userTeamsRes] = await Promise.all([
71
+ api.get('/admin/teams'),
72
+ api.get(`/admin/users/${userId}/teams`).catch(() => ({ data: [] })),
73
+ ]);
74
+ setAllTeams(teamsRes.data);
75
+ setUserTeams(userTeamsRes.data || []);
76
+ } else if (mode === 'team' && teamId) {
77
+ const [usersRes, teamRes] = await Promise.all([
78
+ api.get('/admin/users'),
79
+ api.get(`/admin/teams/${teamId}`),
80
+ ]);
81
+ setAllUsers(Array.isArray(usersRes.data) ? usersRes.data : []);
82
+ setTeamMembers(teamRes.data.members || []);
83
+ }
84
+ } catch (error) {
85
+ console.error('Failed to load data:', error);
86
+ } finally {
87
+ setLoading(false);
88
+ }
89
+ }
90
+
91
+ async function handleAdd(userIdToAdd: string, teamIdToAdd: string) {
92
+ try {
93
+ setSaving(true);
94
+ await api.post(`/admin/teams/${teamIdToAdd}/members`, { user_id: userIdToAdd });
95
+ setSearchQuery('');
96
+ await loadData();
97
+ onSuccess();
98
+ } catch (error: any) {
99
+ alert(error.response?.data?.error || t('common.error'));
100
+ } finally {
101
+ setSaving(false);
102
+ }
103
+ }
104
+
105
+ async function handleRemove(userIdToRemove: string, teamIdToRemove: string) {
106
+ try {
107
+ setSaving(true);
108
+ await api.delete(`/admin/teams/${teamIdToRemove}/members/${userIdToRemove}`);
109
+ setSearchQuery('');
110
+ await loadData();
111
+ onSuccess();
112
+ } catch (error: any) {
113
+ alert(error.response?.data?.error || t('common.error'));
114
+ } finally {
115
+ setSaving(false);
116
+ }
117
+ }
118
+
119
+ const filterTeams = (teams: Team[]) => {
120
+ if (!searchQuery.trim()) return teams;
121
+ const query = searchQuery.toLowerCase();
122
+ return teams.filter(
123
+ (team) =>
124
+ team.name.toLowerCase().includes(query) ||
125
+ (team.description && team.description.toLowerCase().includes(query))
126
+ );
127
+ };
128
+
129
+ const filterUsers = (users: User[]) => {
130
+ if (!searchQuery.trim()) return users;
131
+ const query = searchQuery.toLowerCase();
132
+ return users.filter(
133
+ (user) =>
134
+ user.name.toLowerCase().includes(query) ||
135
+ user.email.toLowerCase().includes(query)
136
+ );
137
+ };
138
+
139
+ if (mode === 'user') {
140
+ const userTeamIds = new Set(userTeams.map((t) => t.id));
141
+ const availableTeams = filterTeams(allTeams.filter((t) => !userTeamIds.has(t.id)));
142
+
143
+ return (
144
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
145
+ <DialogContent className="max-w-2xl max-h-[calc(100vh-4rem)] overflow-y-auto">
146
+ <DialogHeader>
147
+ <DialogTitle>{t('admin.manageTeamsTitle', { userName })}</DialogTitle>
148
+ </DialogHeader>
149
+ <Separator />
150
+
151
+ {loading ? (
152
+ <div className="text-center py-8 text-muted-foreground">{t('common.loading')}</div>
153
+ ) : (
154
+ <div className="space-y-6">
155
+ <div>
156
+ <h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
157
+ <Users className="h-4 w-4" />
158
+ {t('admin.currentTeams')} ({userTeams.length})
159
+ </h3>
160
+ {userTeams.length === 0 ? (
161
+ <p className="text-sm text-muted-foreground py-4">{t('admin.noTeams')}</p>
162
+ ) : (
163
+ <div className="space-y-2">
164
+ {userTeams.map((team) => (
165
+ <div
166
+ key={team.id}
167
+ className="flex items-center justify-between px-4 py-3 rounded-lg border bg-muted/50"
168
+ >
169
+ <div>
170
+ <p className="text-sm font-medium">{team.name}</p>
171
+ {team.description && (
172
+ <p className="text-xs text-muted-foreground">{team.description}</p>
173
+ )}
174
+ </div>
175
+ <Button
176
+ variant="danger"
177
+ size="sm"
178
+ icon={X}
179
+ onClick={() => userId && handleRemove(userId, team.id)}
180
+ disabled={saving}
181
+ />
182
+ </div>
183
+ ))}
184
+ </div>
185
+ )}
186
+ </div>
187
+
188
+ <div>
189
+ <h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
190
+ <UserPlus className="h-4 w-4" />
191
+ {t('admin.addTeams')} ({availableTeams.length})
192
+ </h3>
193
+ <div className="relative mb-3">
194
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
195
+ <Input
196
+ type="text"
197
+ placeholder={t('admin.searchTeams')}
198
+ value={searchQuery}
199
+ onChange={(e) => setSearchQuery(e.target.value)}
200
+ className="pl-9"
201
+ />
202
+ </div>
203
+ {availableTeams.length === 0 ? (
204
+ <p className="text-sm text-muted-foreground py-4 text-center">
205
+ {allTeams.filter((t) => !userTeamIds.has(t.id)).length === 0
206
+ ? t('admin.noTeamsAvailable')
207
+ : t('admin.noSearchResults')}
208
+ </p>
209
+ ) : (
210
+ <div className="space-y-2 max-h-64 overflow-y-auto">
211
+ {availableTeams.map((team) => (
212
+ <div
213
+ key={team.id}
214
+ className="flex items-center justify-between px-4 py-3 rounded-lg border hover:border-primary/50 transition-colors"
215
+ >
216
+ <div>
217
+ <p className="text-sm font-medium">{team.name}</p>
218
+ {team.description && (
219
+ <p className="text-xs text-muted-foreground">{team.description}</p>
220
+ )}
221
+ </div>
222
+ <Button
223
+ variant="primary"
224
+ size="sm"
225
+ icon={UserPlus}
226
+ onClick={() => userId && handleAdd(userId, team.id)}
227
+ disabled={saving}
228
+ >
229
+ {t('admin.add')}
230
+ </Button>
231
+ </div>
232
+ ))}
233
+ </div>
234
+ )}
235
+ </div>
236
+ </div>
237
+ )}
238
+
239
+ <Separator />
240
+ <DialogFooter className="flex-row justify-end">
241
+ <Button variant="secondary" onClick={onClose}>
242
+ {t('common.close')}
243
+ </Button>
244
+ </DialogFooter>
245
+ </DialogContent>
246
+ </Dialog>
247
+ );
248
+ }
249
+
250
+ const memberIds = new Set(teamMembers.map((m) => m.id));
251
+ const availableUsers = filterUsers(allUsers.filter((u) => !memberIds.has(u.id)));
252
+
253
+ return (
254
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
255
+ <DialogContent className="max-w-2xl max-h-[calc(100vh-4rem)] overflow-y-auto">
256
+ <DialogHeader>
257
+ <DialogTitle>{t('admin.manageMembersTitle', { teamName })}</DialogTitle>
258
+ </DialogHeader>
259
+ <Separator />
260
+
261
+ {loading ? (
262
+ <div className="text-center py-8 text-muted-foreground">{t('common.loading')}</div>
263
+ ) : (
264
+ <div className="space-y-6">
265
+ <div>
266
+ <h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
267
+ <Users className="h-4 w-4" />
268
+ {t('admin.currentMembers')} ({teamMembers.length})
269
+ </h3>
270
+ {teamMembers.length === 0 ? (
271
+ <p className="text-sm text-muted-foreground py-4">{t('admin.noMembers')}</p>
272
+ ) : (
273
+ <div className="space-y-2">
274
+ {teamMembers.map((member) => (
275
+ <div
276
+ key={member.id}
277
+ className="flex items-center justify-between px-4 py-3 rounded-lg border bg-muted/50"
278
+ >
279
+ <div>
280
+ <p className="text-sm font-medium">{member.name}</p>
281
+ <p className="text-xs text-muted-foreground">{member.email}</p>
282
+ </div>
283
+ <Button
284
+ variant="danger"
285
+ size="sm"
286
+ icon={X}
287
+ onClick={() => teamId && handleRemove(member.id, teamId)}
288
+ disabled={saving}
289
+ />
290
+ </div>
291
+ ))}
292
+ </div>
293
+ )}
294
+ </div>
295
+
296
+ <div>
297
+ <h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
298
+ <UserPlus className="h-4 w-4" />
299
+ {t('admin.addMembers')} ({availableUsers.length})
300
+ </h3>
301
+ <div className="relative mb-3">
302
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
303
+ <Input
304
+ type="text"
305
+ placeholder={t('admin.searchUsers')}
306
+ value={searchQuery}
307
+ onChange={(e) => setSearchQuery(e.target.value)}
308
+ className="pl-9"
309
+ />
310
+ </div>
311
+ {availableUsers.length === 0 ? (
312
+ <p className="text-sm text-muted-foreground py-4 text-center">
313
+ {allUsers.filter((u) => !memberIds.has(u.id)).length === 0
314
+ ? t('admin.noUsersAvailable')
315
+ : t('admin.noSearchResults')}
316
+ </p>
317
+ ) : (
318
+ <div className="space-y-2 max-h-64 overflow-y-auto">
319
+ {availableUsers.map((user) => (
320
+ <div
321
+ key={user.id}
322
+ className="flex items-center justify-between px-4 py-3 rounded-lg border hover:border-primary/50 transition-colors"
323
+ >
324
+ <div>
325
+ <p className="text-sm font-medium">{user.name}</p>
326
+ <p className="text-xs text-muted-foreground">{user.email}</p>
327
+ </div>
328
+ <Button
329
+ variant="primary"
330
+ size="sm"
331
+ icon={UserPlus}
332
+ onClick={() => teamId && handleAdd(user.id, teamId)}
333
+ disabled={saving}
334
+ >
335
+ {t('admin.add')}
336
+ </Button>
337
+ </div>
338
+ ))}
339
+ </div>
340
+ )}
341
+ </div>
342
+ </div>
343
+ )}
344
+
345
+ <Separator />
346
+ <DialogFooter className="flex-row justify-end">
347
+ <Button variant="secondary" onClick={onClose}>
348
+ {t('common.close')}
349
+ </Button>
350
+ </DialogFooter>
351
+ </DialogContent>
352
+ </Dialog>
353
+ );
354
+ }
@@ -0,0 +1,117 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import api from '../../api/client';
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ DialogFooter,
10
+ } from '../ui/dialog';
11
+ import { Separator } from '../ui/separator';
12
+ import { FormFieldWrapper } from '../ui/FormFieldWrapper';
13
+ import { ModalSection } from '../ui/ModalSection';
14
+ import { ModalFooterActions } from '../ui/ModalFooterActions';
15
+ import { Input } from '../ui/input';
16
+
17
+ interface Team {
18
+ id: string;
19
+ name: string;
20
+ description: string | null;
21
+ }
22
+
23
+ interface TeamModalProps {
24
+ team: Team | null;
25
+ isOpen: boolean;
26
+ onClose: () => void;
27
+ onSuccess: () => void;
28
+ }
29
+
30
+ export default function TeamModal({ team, isOpen, onClose, onSuccess }: TeamModalProps) {
31
+ const { t } = useTranslation();
32
+ const [formData, setFormData] = useState({
33
+ name: '',
34
+ description: '',
35
+ });
36
+ const [loading, setLoading] = useState(false);
37
+ const [error, setError] = useState('');
38
+
39
+ useEffect(() => {
40
+ if (team) {
41
+ setFormData({
42
+ name: team.name,
43
+ description: team.description || '',
44
+ });
45
+ } else {
46
+ setFormData({ name: '', description: '' });
47
+ }
48
+ setError('');
49
+ }, [team, isOpen]);
50
+
51
+ async function handleSubmit(e: React.FormEvent) {
52
+ e.preventDefault();
53
+ setLoading(true);
54
+ setError('');
55
+
56
+ try {
57
+ if (team) {
58
+ await api.put(`/admin/teams/${team.id}`, formData);
59
+ } else {
60
+ await api.post('/admin/teams', formData);
61
+ }
62
+ onSuccess();
63
+ onClose();
64
+ } catch (err: any) {
65
+ setError(err.response?.data?.error || t('common.error'));
66
+ } finally {
67
+ setLoading(false);
68
+ }
69
+ }
70
+
71
+ const isValid = formData.name.trim();
72
+
73
+ return (
74
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
75
+ <DialogContent className="max-w-[460px]">
76
+ <DialogHeader>
77
+ <DialogTitle>{team ? t('admin.editTeam') : t('admin.addTeam')}</DialogTitle>
78
+ </DialogHeader>
79
+ <Separator />
80
+
81
+ <form id="team-form" onSubmit={handleSubmit} className="space-y-6">
82
+ <ModalSection>
83
+ <FormFieldWrapper label={t('admin.teamName')} required error={error}>
84
+ <Input
85
+ type="text"
86
+ required
87
+ value={formData.name}
88
+ onChange={(e) => setFormData({ ...formData, name: e.target.value })}
89
+ placeholder={t('admin.teamName')}
90
+ />
91
+ </FormFieldWrapper>
92
+ <FormFieldWrapper label={t('admin.description')}>
93
+ <textarea
94
+ rows={3}
95
+ className="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none"
96
+ value={formData.description}
97
+ onChange={(e) => setFormData({ ...formData, description: e.target.value })}
98
+ placeholder={t('admin.description')}
99
+ />
100
+ </FormFieldWrapper>
101
+ </ModalSection>
102
+ </form>
103
+
104
+ <Separator />
105
+ <DialogFooter className="flex-row justify-between sm:justify-end gap-2">
106
+ <ModalFooterActions
107
+ onCancel={onClose}
108
+ submitLabel={t('common.save')}
109
+ loading={loading}
110
+ submitDisabled={!isValid}
111
+ formId="team-form"
112
+ />
113
+ </DialogFooter>
114
+ </DialogContent>
115
+ </Dialog>
116
+ );
117
+ }
@@ -0,0 +1,225 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Shield } from 'lucide-react';
4
+ import api from '../../api/client';
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ DialogFooter,
11
+ } from '../ui/dialog';
12
+ import { Separator } from '../ui/separator';
13
+ import { FormFieldWrapper } from '../ui/FormFieldWrapper';
14
+ import { ModalSection } from '../ui/ModalSection';
15
+ import { ModalFooterActions } from '../ui/ModalFooterActions';
16
+ import { Switch } from '../ui/switch';
17
+ import { Label } from '../ui/label';
18
+ import { Input } from '../ui/input';
19
+ import { useToast } from '../ui/Toast';
20
+
21
+ interface User {
22
+ id: string;
23
+ email: string;
24
+ name: string;
25
+ is_admin: boolean;
26
+ oidc_provider: string | null;
27
+ }
28
+
29
+ interface UserModalProps {
30
+ user: User | null;
31
+ isOpen: boolean;
32
+ onClose: () => void;
33
+ onSuccess: () => void;
34
+ }
35
+
36
+ type CreateMode = 'set_password' | 'send_invite';
37
+
38
+ export default function UserModal({ user, isOpen, onClose, onSuccess }: UserModalProps) {
39
+ const { t } = useTranslation();
40
+ const { showToast } = useToast();
41
+ const [formData, setFormData] = useState({
42
+ email: '',
43
+ name: '',
44
+ password: '',
45
+ is_admin: false,
46
+ });
47
+ const [loading, setLoading] = useState(false);
48
+ const [error, setError] = useState('');
49
+ const [inviteEnabled, setInviteEnabled] = useState(false);
50
+ const [createMode, setCreateMode] = useState<CreateMode>('set_password');
51
+
52
+ useEffect(() => {
53
+ if (user) {
54
+ setFormData({
55
+ email: user.email,
56
+ name: user.name,
57
+ password: '',
58
+ is_admin: user.is_admin,
59
+ });
60
+ } else {
61
+ setFormData({ email: '', name: '', password: '', is_admin: false });
62
+ setCreateMode('set_password');
63
+ }
64
+ setError('');
65
+ }, [user, isOpen]);
66
+
67
+ useEffect(() => {
68
+ if (!user && isOpen) {
69
+ api
70
+ .get('/admin/settings')
71
+ .then((res) => {
72
+ setInviteEnabled(res.data?.smtp_enabled === 'true');
73
+ })
74
+ .catch(() => setInviteEnabled(false));
75
+ }
76
+ }, [user, isOpen]);
77
+
78
+ async function handleSubmit(e: React.FormEvent) {
79
+ e.preventDefault();
80
+ setLoading(true);
81
+ setError('');
82
+
83
+ try {
84
+ const payload: any = { email: formData.email, name: formData.name, is_admin: formData.is_admin };
85
+ if (user) {
86
+ if (formData.password) payload.password = formData.password;
87
+ await api.put(`/admin/users/${user.id}`, payload);
88
+ showToast(t('common.success'), 'success');
89
+ } else {
90
+ if (createMode === 'send_invite') {
91
+ payload.send_invite = true;
92
+ } else if (formData.password) {
93
+ payload.password = formData.password;
94
+ }
95
+ const response = await api.post('/admin/users', payload);
96
+ if (payload.send_invite && response.data?.inviteSent === false) {
97
+ showToast(t('admin.userCreatedInviteNotSent'), 'warning');
98
+ } else if (payload.send_invite && response.data?.inviteSent === true) {
99
+ showToast(t('admin.userCreatedInviteSent'), 'success');
100
+ } else {
101
+ showToast(t('common.success'), 'success');
102
+ }
103
+ }
104
+ onSuccess();
105
+ onClose();
106
+ } catch (err: any) {
107
+ setError(err.response?.data?.error || t('common.error'));
108
+ } finally {
109
+ setLoading(false);
110
+ }
111
+ }
112
+
113
+ const isCreate = !user;
114
+ const useInvite = isCreate && inviteEnabled && createMode === 'send_invite';
115
+ const isValid =
116
+ formData.email.trim() &&
117
+ formData.name.trim() &&
118
+ (user ? true : useInvite ? true : formData.password.length >= 8);
119
+
120
+ return (
121
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
122
+ <DialogContent className="max-w-[460px]">
123
+ <DialogHeader>
124
+ <DialogTitle>{user ? t('admin.editUser') : t('admin.addUser')}</DialogTitle>
125
+ </DialogHeader>
126
+ <Separator />
127
+
128
+ <form id="user-form" onSubmit={handleSubmit} className="space-y-6">
129
+ <ModalSection>
130
+ <FormFieldWrapper label={t('auth.email')} required error={error}>
131
+ <Input
132
+ type="email"
133
+ required
134
+ value={formData.email}
135
+ onChange={(e) => setFormData({ ...formData, email: e.target.value })}
136
+ placeholder={t('auth.email')}
137
+ />
138
+ </FormFieldWrapper>
139
+ <FormFieldWrapper label={t('setup.name')} required>
140
+ <Input
141
+ type="text"
142
+ required
143
+ value={formData.name}
144
+ onChange={(e) => setFormData({ ...formData, name: e.target.value })}
145
+ placeholder={t('setup.name')}
146
+ />
147
+ </FormFieldWrapper>
148
+ {isCreate && inviteEnabled && (
149
+ <div className="space-y-3">
150
+ <Label className="text-sm font-medium">{t('admin.createUserWith')}</Label>
151
+ <div className="flex flex-col gap-2" role="radiogroup" aria-label="Create user with">
152
+ <div className="flex items-center space-x-2">
153
+ <input
154
+ type="radio"
155
+ id="create-set-password"
156
+ name="create-mode"
157
+ value="set_password"
158
+ checked={createMode === 'set_password'}
159
+ onChange={() => setCreateMode('set_password')}
160
+ className="h-4 w-4 rounded-full border-input"
161
+ />
162
+ <Label htmlFor="create-set-password" className="font-normal cursor-pointer">
163
+ {t('admin.setPassword')}
164
+ </Label>
165
+ </div>
166
+ <div className="flex items-center space-x-2">
167
+ <input
168
+ type="radio"
169
+ id="create-send-invite"
170
+ name="create-mode"
171
+ value="send_invite"
172
+ checked={createMode === 'send_invite'}
173
+ onChange={() => setCreateMode('send_invite')}
174
+ className="h-4 w-4 rounded-full border-input"
175
+ />
176
+ <Label htmlFor="create-send-invite" className="font-normal cursor-pointer">
177
+ {t('admin.sendInviteEmail')}
178
+ </Label>
179
+ </div>
180
+ </div>
181
+ </div>
182
+ )}
183
+ {!useInvite && (
184
+ <FormFieldWrapper
185
+ label={user ? `${t('auth.password')} (${t('admin.leaveBlank')})` : t('auth.password')}
186
+ required={!user}
187
+ >
188
+ <Input
189
+ type="password"
190
+ minLength={8}
191
+ required={!user}
192
+ value={formData.password}
193
+ onChange={(e) => setFormData({ ...formData, password: e.target.value })}
194
+ placeholder={user ? t('admin.leaveBlank') : ''}
195
+ />
196
+ </FormFieldWrapper>
197
+ )}
198
+ <div className="flex items-center justify-between rounded-lg border p-3">
199
+ <Label htmlFor="is_admin" className="text-sm font-medium cursor-pointer flex items-center gap-2">
200
+ <Shield className="h-4 w-4" />
201
+ {t('admin.admin')}
202
+ </Label>
203
+ <Switch
204
+ id="is_admin"
205
+ checked={formData.is_admin}
206
+ onCheckedChange={(checked) => setFormData({ ...formData, is_admin: checked })}
207
+ />
208
+ </div>
209
+ </ModalSection>
210
+ </form>
211
+
212
+ <Separator />
213
+ <DialogFooter className="flex-row justify-between sm:justify-end gap-2">
214
+ <ModalFooterActions
215
+ onCancel={onClose}
216
+ submitLabel={t('common.save')}
217
+ loading={loading}
218
+ submitDisabled={!isValid}
219
+ formId="user-form"
220
+ />
221
+ </DialogFooter>
222
+ </DialogContent>
223
+ </Dialog>
224
+ );
225
+ }