@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.
- package/frontend/index.tsx +3 -3
- package/frontend/public/favicon.svg +1 -0
- package/frontend/public/slugbase_icon_blue.svg +1 -0
- package/frontend/public/slugbase_icon_white.png +0 -0
- package/frontend/public/slugbase_icon_white.svg +1 -0
- package/frontend/src/App.tsx +179 -0
- package/frontend/src/api/client.ts +134 -0
- package/frontend/src/components/AppSidebar.tsx +214 -0
- package/frontend/src/components/EmptyState.tsx +33 -0
- package/frontend/src/components/Favicon.tsx +76 -0
- package/frontend/src/components/FilterChips.tsx +60 -0
- package/frontend/src/components/FolderIcon.tsx +207 -0
- package/frontend/src/components/GlobalSearch.tsx +275 -0
- package/frontend/src/components/Layout.tsx +60 -0
- package/frontend/src/components/PageHeader.tsx +31 -0
- package/frontend/src/components/ScopeSegmentedControl.tsx +42 -0
- package/frontend/src/components/SentryDebug.tsx +32 -0
- package/frontend/src/components/StatCard.tsx +66 -0
- package/frontend/src/components/TopBar.tsx +63 -0
- package/frontend/src/components/UserDropdown.tsx +86 -0
- package/frontend/src/components/admin/AdminAI.tsx +207 -0
- package/frontend/src/components/admin/AdminOIDCProviders.tsx +183 -0
- package/frontend/src/components/admin/AdminSettings.tsx +413 -0
- package/frontend/src/components/admin/AdminTeams.tsx +177 -0
- package/frontend/src/components/admin/AdminUsers.tsx +225 -0
- package/frontend/src/components/bookmarks/BookmarkCard.tsx +312 -0
- package/frontend/src/components/bookmarks/BookmarkListItem.tsx +159 -0
- package/frontend/src/components/bookmarks/BookmarkTableView.tsx +419 -0
- package/frontend/src/components/bookmarks/BulkActionModals.tsx +162 -0
- package/frontend/src/components/bookmarks/FilterChips.tsx +5 -0
- package/frontend/src/components/modals/BookmarkModal.tsx +493 -0
- package/frontend/src/components/modals/FolderModal.tsx +306 -0
- package/frontend/src/components/modals/ImportModal.tsx +232 -0
- package/frontend/src/components/modals/OIDCProviderModal.tsx +284 -0
- package/frontend/src/components/modals/SharingModal.tsx +96 -0
- package/frontend/src/components/modals/TagModal.tsx +101 -0
- package/frontend/src/components/modals/TeamAssignmentModal.tsx +354 -0
- package/frontend/src/components/modals/TeamModal.tsx +117 -0
- package/frontend/src/components/modals/UserModal.tsx +225 -0
- package/frontend/src/components/profile/CreateTokenModal.tsx +172 -0
- package/frontend/src/components/sharing/ShareResourceDialog.tsx +422 -0
- package/frontend/src/components/ui/Autocomplete.tsx +155 -0
- package/frontend/src/components/ui/Button.tsx +68 -0
- package/frontend/src/components/ui/ConfirmDialog.tsx +79 -0
- package/frontend/src/components/ui/FormFieldWrapper.tsx +36 -0
- package/frontend/src/components/ui/ModalFooterActions.tsx +49 -0
- package/frontend/src/components/ui/ModalSection.tsx +34 -0
- package/frontend/src/components/ui/PageLoadingSkeleton.tsx +24 -0
- package/frontend/src/components/ui/Select.tsx +61 -0
- package/frontend/src/components/ui/SharingField.tsx +298 -0
- package/frontend/src/components/ui/Toast.tsx +47 -0
- package/frontend/src/components/ui/Tooltip.tsx +21 -0
- package/frontend/src/components/ui/alert-dialog.tsx +139 -0
- package/frontend/src/components/ui/badge.tsx +36 -0
- package/frontend/src/components/ui/button-base.tsx +57 -0
- package/frontend/src/components/ui/card.tsx +76 -0
- package/frontend/src/components/ui/command.tsx +161 -0
- package/frontend/src/components/ui/dialog.tsx +120 -0
- package/frontend/src/components/ui/dropdown-menu.tsx +199 -0
- package/frontend/src/components/ui/input.tsx +22 -0
- package/frontend/src/components/ui/label.tsx +24 -0
- package/frontend/src/components/ui/popover.tsx +33 -0
- package/frontend/src/components/ui/progress.tsx +26 -0
- package/frontend/src/components/ui/scroll-area.tsx +48 -0
- package/frontend/src/components/ui/select-base.tsx +159 -0
- package/frontend/src/components/ui/separator.tsx +29 -0
- package/frontend/src/components/ui/sheet.tsx +140 -0
- package/frontend/src/components/ui/sidebar.tsx +783 -0
- package/frontend/src/components/ui/skeleton.tsx +15 -0
- package/frontend/src/components/ui/sonner.tsx +46 -0
- package/frontend/src/components/ui/switch.tsx +28 -0
- package/frontend/src/components/ui/table.tsx +120 -0
- package/frontend/src/components/ui/tooltip-base.tsx +30 -0
- package/frontend/src/config/api.ts +16 -0
- package/frontend/src/config/mode.ts +6 -0
- package/frontend/src/contexts/AppConfigContext.tsx +39 -0
- package/frontend/src/contexts/AuthContext.tsx +137 -0
- package/frontend/src/contexts/SearchCommandContext.tsx +28 -0
- package/frontend/src/hooks/use-mobile.tsx +19 -0
- package/frontend/src/hooks/useConfirmDialog.ts +63 -0
- package/frontend/src/hooks/useMarketingTheme.ts +47 -0
- package/frontend/src/i18n.ts +39 -0
- package/frontend/src/index.css +117 -0
- package/frontend/src/instrument.ts +20 -0
- package/frontend/src/lib/utils.ts +6 -0
- package/frontend/src/locales/de.json +899 -0
- package/frontend/src/locales/en.json +937 -0
- package/frontend/src/locales/es.json +884 -0
- package/frontend/src/locales/fr.json +550 -0
- package/frontend/src/locales/it.json +535 -0
- package/frontend/src/locales/ja.json +535 -0
- package/frontend/src/locales/nl.json +550 -0
- package/frontend/src/locales/pl.json +535 -0
- package/frontend/src/locales/pt.json +535 -0
- package/frontend/src/locales/ru.json +535 -0
- package/frontend/src/locales/zh.json +535 -0
- package/frontend/src/main.tsx +44 -0
- package/frontend/src/pages/Bookmarks.tsx +1004 -0
- package/frontend/src/pages/Dashboard.tsx +427 -0
- package/frontend/src/pages/Folders.tsx +578 -0
- package/frontend/src/pages/GoPreferences.tsx +134 -0
- package/frontend/src/pages/Login.tsx +196 -0
- package/frontend/src/pages/PasswordReset.tsx +242 -0
- package/frontend/src/pages/Profile.tsx +593 -0
- package/frontend/src/pages/SearchEngineGuide.tsx +135 -0
- package/frontend/src/pages/Setup.tsx +210 -0
- package/frontend/src/pages/Signup.tsx +199 -0
- package/frontend/src/pages/Tags.tsx +421 -0
- package/frontend/src/pages/VerifyEmail.tsx +254 -0
- package/frontend/src/pages/admin/AdminAIPage.tsx +5 -0
- package/frontend/src/pages/admin/AdminLayout.tsx +40 -0
- package/frontend/src/pages/admin/AdminMembersPage.tsx +5 -0
- package/frontend/src/pages/admin/AdminOIDCPage.tsx +5 -0
- package/frontend/src/pages/admin/AdminSettingsPage.tsx +5 -0
- package/frontend/src/pages/admin/AdminTeamsPage.tsx +5 -0
- package/frontend/src/utils/favicon.ts +36 -0
- package/frontend/src/utils/formatRelativeTime.ts +37 -0
- package/frontend/src/utils/safeHref.ts +31 -0
- package/frontend/src/vite-env.d.ts +10 -0
- package/package.json +9 -1
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { useAuth } from '../../contexts/AuthContext';
|
|
4
|
+
import api from '../../api/client';
|
|
5
|
+
import { useToast } from '../ui/Toast';
|
|
6
|
+
import { Save, Plus, Trash2, Settings as SettingsIcon, Mail, Send } from 'lucide-react';
|
|
7
|
+
import Button from '../ui/Button';
|
|
8
|
+
import ConfirmDialog from '../ui/ConfirmDialog';
|
|
9
|
+
import { useConfirmDialog } from '../../hooks/useConfirmDialog';
|
|
10
|
+
import { PageLoadingSkeleton } from '../ui/PageLoadingSkeleton';
|
|
11
|
+
|
|
12
|
+
export default function AdminSettings() {
|
|
13
|
+
const { t } = useTranslation();
|
|
14
|
+
const { user } = useAuth();
|
|
15
|
+
const { showConfirm, dialogState } = useConfirmDialog();
|
|
16
|
+
const { showToast } = useToast();
|
|
17
|
+
const [settings, setSettings] = useState<Record<string, string>>({});
|
|
18
|
+
const [loading, setLoading] = useState(true);
|
|
19
|
+
const [newKey, setNewKey] = useState('');
|
|
20
|
+
const [newValue, setNewValue] = useState('');
|
|
21
|
+
|
|
22
|
+
// SMTP settings state
|
|
23
|
+
const [smtpSettings, setSmtpSettings] = useState({
|
|
24
|
+
enabled: false,
|
|
25
|
+
host: '',
|
|
26
|
+
port: 587,
|
|
27
|
+
secure: false,
|
|
28
|
+
user: '',
|
|
29
|
+
password: '',
|
|
30
|
+
from: '',
|
|
31
|
+
fromName: 'SlugBase',
|
|
32
|
+
});
|
|
33
|
+
const [passwordIsSet, setPasswordIsSet] = useState(false);
|
|
34
|
+
const [testingEmail, setTestingEmail] = useState(false);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
loadSettings();
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
const loadSettings = async () => {
|
|
41
|
+
try {
|
|
42
|
+
const response = await api.get('/admin/settings');
|
|
43
|
+
const allSettings = response.data;
|
|
44
|
+
setSettings(allSettings);
|
|
45
|
+
|
|
46
|
+
// Load SMTP settings
|
|
47
|
+
// Check if password is set (backend returns '***SET***' if password exists)
|
|
48
|
+
const hasPassword = allSettings.smtp_password === '***SET***';
|
|
49
|
+
setPasswordIsSet(hasPassword);
|
|
50
|
+
|
|
51
|
+
setSmtpSettings({
|
|
52
|
+
enabled: allSettings.smtp_enabled === 'true',
|
|
53
|
+
host: allSettings.smtp_host || '',
|
|
54
|
+
port: parseInt(allSettings.smtp_port || '587'),
|
|
55
|
+
secure: allSettings.smtp_secure === 'true',
|
|
56
|
+
user: allSettings.smtp_user || '',
|
|
57
|
+
password: hasPassword ? '••••••••••••••••' : '', // Show masked password if set, empty if not
|
|
58
|
+
from: allSettings.smtp_from || '',
|
|
59
|
+
fromName: allSettings.smtp_from_name || 'SlugBase',
|
|
60
|
+
});
|
|
61
|
+
} catch (error) {
|
|
62
|
+
console.error('Failed to load settings:', error);
|
|
63
|
+
} finally {
|
|
64
|
+
setLoading(false);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const handleSave = async (key: string, value: string) => {
|
|
69
|
+
try {
|
|
70
|
+
await api.post('/admin/settings', { key, value });
|
|
71
|
+
setSettings({ ...settings, [key]: value });
|
|
72
|
+
showToast(t('common.success'), 'success');
|
|
73
|
+
} catch (error: any) {
|
|
74
|
+
showToast(error.response?.data?.error || t('common.error'), 'error');
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const handleAdd = async (e: React.FormEvent) => {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
if (!newKey) return;
|
|
81
|
+
try {
|
|
82
|
+
await api.post('/admin/settings', { key: newKey, value: newValue });
|
|
83
|
+
setSettings({ ...settings, [newKey]: newValue });
|
|
84
|
+
setNewKey('');
|
|
85
|
+
setNewValue('');
|
|
86
|
+
showToast(t('common.success'), 'success');
|
|
87
|
+
} catch (error: any) {
|
|
88
|
+
showToast(error.response?.data?.error || t('common.error'), 'error');
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const handleDelete = (key: string) => {
|
|
93
|
+
showConfirm(
|
|
94
|
+
t('admin.confirmDeleteSetting'),
|
|
95
|
+
t('admin.confirmDeleteSetting'),
|
|
96
|
+
async () => {
|
|
97
|
+
try {
|
|
98
|
+
await api.delete(`/admin/settings/${key}`);
|
|
99
|
+
const newSettings = { ...settings };
|
|
100
|
+
delete newSettings[key];
|
|
101
|
+
setSettings(newSettings);
|
|
102
|
+
showToast(t('common.success'), 'success');
|
|
103
|
+
} catch (error: any) {
|
|
104
|
+
showToast(error.response?.data?.error || t('common.error'), 'error');
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
{ variant: 'danger', confirmText: t('common.delete'), cancelText: t('common.cancel') }
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const handleSMTPSave = async () => {
|
|
112
|
+
try {
|
|
113
|
+
// If password field contains the masked string, don't send it (keep existing password)
|
|
114
|
+
const settingsToSave: any = { ...smtpSettings };
|
|
115
|
+
if (settingsToSave.password === '••••••••••••••••') {
|
|
116
|
+
// Password is masked, don't send it to keep existing password
|
|
117
|
+
delete settingsToSave.password;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await api.post('/admin/settings/smtp', settingsToSave);
|
|
121
|
+
showToast(t('common.success'), 'success');
|
|
122
|
+
await loadSettings();
|
|
123
|
+
} catch (error: any) {
|
|
124
|
+
showToast(error.response?.data?.error || t('common.error'), 'error');
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const handleTestEmail = async () => {
|
|
129
|
+
if (!user?.email) {
|
|
130
|
+
showToast(t('admin.noUserEmailAvailable'), 'warning');
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
setTestingEmail(true);
|
|
134
|
+
try {
|
|
135
|
+
await api.post('/admin/settings/smtp/test', { email: user.email });
|
|
136
|
+
showToast(t('smtp.testSent'), 'success');
|
|
137
|
+
} catch (error: any) {
|
|
138
|
+
showToast(error.response?.data?.error || t('smtp.testFailed'), 'error');
|
|
139
|
+
} finally {
|
|
140
|
+
setTestingEmail(false);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
if (loading) {
|
|
145
|
+
return <PageLoadingSkeleton lines={8} />;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Filter out SMTP settings from general settings display
|
|
149
|
+
const generalSettings = Object.fromEntries(
|
|
150
|
+
Object.entries(settings).filter(([key]) => !key.startsWith('smtp_'))
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<div className="space-y-6">
|
|
155
|
+
{/* SMTP Settings Section */}
|
|
156
|
+
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm p-4">
|
|
157
|
+
<div className="flex items-center gap-2 mb-6">
|
|
158
|
+
<Mail className="h-5 w-5 text-gray-400 dark:text-gray-500" />
|
|
159
|
+
<div>
|
|
160
|
+
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
|
161
|
+
{t('smtp.title')}
|
|
162
|
+
</h2>
|
|
163
|
+
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
164
|
+
{t('smtp.description')}
|
|
165
|
+
</p>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<div className="space-y-4">
|
|
170
|
+
<div className="flex items-center gap-3">
|
|
171
|
+
<input
|
|
172
|
+
type="checkbox"
|
|
173
|
+
id="smtp-enabled"
|
|
174
|
+
checked={smtpSettings.enabled}
|
|
175
|
+
onChange={(e) => setSmtpSettings({ ...smtpSettings, enabled: e.target.checked })}
|
|
176
|
+
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
177
|
+
/>
|
|
178
|
+
<label htmlFor="smtp-enabled" className="text-sm font-medium text-gray-900 dark:text-white">
|
|
179
|
+
{t('smtp.enabled')}
|
|
180
|
+
</label>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
184
|
+
<div>
|
|
185
|
+
<label className="block text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
|
186
|
+
{t('smtp.host')}
|
|
187
|
+
</label>
|
|
188
|
+
<input
|
|
189
|
+
type="text"
|
|
190
|
+
className="w-full px-4 h-9 text-sm text-gray-900 dark:text-white bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
191
|
+
placeholder={t('smtp.hostPlaceholder')}
|
|
192
|
+
value={smtpSettings.host}
|
|
193
|
+
onChange={(e) => setSmtpSettings({ ...smtpSettings, host: e.target.value })}
|
|
194
|
+
/>
|
|
195
|
+
</div>
|
|
196
|
+
<div>
|
|
197
|
+
<label className="block text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
|
198
|
+
{t('smtp.port')}
|
|
199
|
+
</label>
|
|
200
|
+
<input
|
|
201
|
+
type="number"
|
|
202
|
+
className="w-full px-4 h-9 text-sm text-gray-900 dark:text-white bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
203
|
+
placeholder={t('smtp.portPlaceholder')}
|
|
204
|
+
value={smtpSettings.port}
|
|
205
|
+
onChange={(e) => setSmtpSettings({ ...smtpSettings, port: parseInt(e.target.value) || 587 })}
|
|
206
|
+
/>
|
|
207
|
+
</div>
|
|
208
|
+
<div>
|
|
209
|
+
<label className="block text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
|
210
|
+
{t('smtp.user')}
|
|
211
|
+
</label>
|
|
212
|
+
<input
|
|
213
|
+
type="text"
|
|
214
|
+
className="w-full px-4 h-9 text-sm text-gray-900 dark:text-white bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
215
|
+
placeholder={t('smtp.userPlaceholder')}
|
|
216
|
+
value={smtpSettings.user}
|
|
217
|
+
onChange={(e) => setSmtpSettings({ ...smtpSettings, user: e.target.value })}
|
|
218
|
+
/>
|
|
219
|
+
</div>
|
|
220
|
+
<div>
|
|
221
|
+
<label className="block text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
|
222
|
+
{t('smtp.password')}
|
|
223
|
+
</label>
|
|
224
|
+
<input
|
|
225
|
+
type="password"
|
|
226
|
+
className="w-full px-4 h-9 text-sm text-gray-900 dark:text-white bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
227
|
+
placeholder={passwordIsSet ? t('smtp.passwordPlaceholder') : t('smtp.passwordPlaceholder')}
|
|
228
|
+
value={smtpSettings.password}
|
|
229
|
+
onChange={(e) => {
|
|
230
|
+
// If user starts typing, clear the masked password
|
|
231
|
+
const newValue = e.target.value;
|
|
232
|
+
if (passwordIsSet && smtpSettings.password === '••••••••••••••••' && newValue.length > 0) {
|
|
233
|
+
// User is typing, clear the masked value
|
|
234
|
+
setSmtpSettings({ ...smtpSettings, password: newValue });
|
|
235
|
+
setPasswordIsSet(false);
|
|
236
|
+
} else {
|
|
237
|
+
setSmtpSettings({ ...smtpSettings, password: newValue });
|
|
238
|
+
if (newValue.length > 0) {
|
|
239
|
+
setPasswordIsSet(true);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}}
|
|
243
|
+
onFocus={() => {
|
|
244
|
+
// Clear masked password when field is focused
|
|
245
|
+
if (smtpSettings.password === '••••••••••••••••') {
|
|
246
|
+
setSmtpSettings({ ...smtpSettings, password: '' });
|
|
247
|
+
setPasswordIsSet(false);
|
|
248
|
+
}
|
|
249
|
+
}}
|
|
250
|
+
/>
|
|
251
|
+
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
252
|
+
{passwordIsSet ? t('smtp.passwordChangeHint') : t('admin.leaveBlank')}
|
|
253
|
+
</p>
|
|
254
|
+
</div>
|
|
255
|
+
<div>
|
|
256
|
+
<label className="block text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
|
257
|
+
{t('smtp.from')}
|
|
258
|
+
</label>
|
|
259
|
+
<input
|
|
260
|
+
type="email"
|
|
261
|
+
className="w-full px-4 h-9 text-sm text-gray-900 dark:text-white bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
262
|
+
placeholder={t('smtp.fromPlaceholder')}
|
|
263
|
+
value={smtpSettings.from}
|
|
264
|
+
onChange={(e) => setSmtpSettings({ ...smtpSettings, from: e.target.value })}
|
|
265
|
+
/>
|
|
266
|
+
</div>
|
|
267
|
+
<div>
|
|
268
|
+
<label className="block text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
|
269
|
+
{t('smtp.fromName')}
|
|
270
|
+
</label>
|
|
271
|
+
<input
|
|
272
|
+
type="text"
|
|
273
|
+
className="w-full px-4 h-9 text-sm text-gray-900 dark:text-white bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
274
|
+
placeholder={t('smtp.fromNamePlaceholder')}
|
|
275
|
+
value={smtpSettings.fromName}
|
|
276
|
+
onChange={(e) => setSmtpSettings({ ...smtpSettings, fromName: e.target.value })}
|
|
277
|
+
/>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
<div className="flex items-center gap-3 pt-2">
|
|
282
|
+
<input
|
|
283
|
+
type="checkbox"
|
|
284
|
+
id="smtp-secure"
|
|
285
|
+
checked={smtpSettings.secure}
|
|
286
|
+
onChange={(e) => setSmtpSettings({ ...smtpSettings, secure: e.target.checked })}
|
|
287
|
+
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
288
|
+
/>
|
|
289
|
+
<label htmlFor="smtp-secure" className="text-sm font-medium text-gray-900 dark:text-white">
|
|
290
|
+
{t('smtp.secure')}
|
|
291
|
+
</label>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
<div className="flex items-center gap-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
295
|
+
<div className="flex-1">
|
|
296
|
+
<label className="block text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
|
297
|
+
{t('smtp.testEmail')}
|
|
298
|
+
</label>
|
|
299
|
+
<div className="px-4 py-2.5 text-sm text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-700/50 border border-gray-300 dark:border-gray-600 rounded-lg">
|
|
300
|
+
{user?.email || t('common.loading')}
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
<div className="pt-6">
|
|
304
|
+
<Button
|
|
305
|
+
variant="ghost"
|
|
306
|
+
icon={Send}
|
|
307
|
+
onClick={handleTestEmail}
|
|
308
|
+
disabled={testingEmail || !user?.email}
|
|
309
|
+
>
|
|
310
|
+
{t('smtp.sendTest')}
|
|
311
|
+
</Button>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
|
|
315
|
+
<div className="pt-4">
|
|
316
|
+
<Button variant="primary" icon={Save} onClick={handleSMTPSave} >
|
|
317
|
+
{t('smtp.save')}
|
|
318
|
+
</Button>
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
|
|
323
|
+
{/* General Settings Section */}
|
|
324
|
+
<div>
|
|
325
|
+
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('admin.settings')}</h2>
|
|
326
|
+
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
327
|
+
{Object.keys(generalSettings).length} {Object.keys(generalSettings).length === 1 ? t('common.setting') : t('common.settings')}
|
|
328
|
+
</p>
|
|
329
|
+
</div>
|
|
330
|
+
|
|
331
|
+
{/* Existing Settings */}
|
|
332
|
+
{Object.keys(generalSettings).length > 0 && (
|
|
333
|
+
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
|
334
|
+
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
335
|
+
{Object.entries(generalSettings).map(([key, value]) => (
|
|
336
|
+
<div key={key} className="px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
|
337
|
+
<div className="flex items-center justify-between gap-4">
|
|
338
|
+
<div className="flex-1 min-w-0">
|
|
339
|
+
<label className="block text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
|
340
|
+
{key}
|
|
341
|
+
</label>
|
|
342
|
+
<input
|
|
343
|
+
type="text"
|
|
344
|
+
className="w-full px-4 h-9 text-sm text-gray-900 dark:text-white bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
|
345
|
+
value={value}
|
|
346
|
+
onChange={(e) => setSettings({ ...settings, [key]: e.target.value })}
|
|
347
|
+
/>
|
|
348
|
+
</div>
|
|
349
|
+
<div className="flex items-center gap-2">
|
|
350
|
+
<Button
|
|
351
|
+
variant="ghost"
|
|
352
|
+
size="sm"
|
|
353
|
+
icon={Save}
|
|
354
|
+
onClick={() => handleSave(key, settings[key])}
|
|
355
|
+
/>
|
|
356
|
+
<Button
|
|
357
|
+
variant="danger"
|
|
358
|
+
size="sm"
|
|
359
|
+
icon={Trash2}
|
|
360
|
+
onClick={() => handleDelete(key)}
|
|
361
|
+
/>
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
))}
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
)}
|
|
369
|
+
|
|
370
|
+
{/* Add New Setting */}
|
|
371
|
+
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm p-4">
|
|
372
|
+
<div className="flex items-center gap-2 mb-4">
|
|
373
|
+
<SettingsIcon className="h-5 w-5 text-gray-400 dark:text-gray-500" />
|
|
374
|
+
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
375
|
+
{t('admin.addSetting')}
|
|
376
|
+
</h3>
|
|
377
|
+
</div>
|
|
378
|
+
<form onSubmit={handleAdd} className="space-y-4">
|
|
379
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
380
|
+
<div>
|
|
381
|
+
<label className="block text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
|
382
|
+
{t('admin.settingKey')}
|
|
383
|
+
</label>
|
|
384
|
+
<input
|
|
385
|
+
type="text"
|
|
386
|
+
required
|
|
387
|
+
className="w-full px-4 py-2.5 text-sm text-gray-900 dark:text-white bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
|
388
|
+
value={newKey}
|
|
389
|
+
onChange={(e) => setNewKey(e.target.value)}
|
|
390
|
+
/>
|
|
391
|
+
</div>
|
|
392
|
+
<div>
|
|
393
|
+
<label className="block text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
|
394
|
+
{t('admin.settingValue')}
|
|
395
|
+
</label>
|
|
396
|
+
<input
|
|
397
|
+
type="text"
|
|
398
|
+
className="w-full px-4 py-2.5 text-sm text-gray-900 dark:text-white bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
|
399
|
+
value={newValue}
|
|
400
|
+
onChange={(e) => setNewValue(e.target.value)}
|
|
401
|
+
/>
|
|
402
|
+
</div>
|
|
403
|
+
</div>
|
|
404
|
+
<Button type="submit" variant="primary" icon={Plus}>
|
|
405
|
+
{t('admin.addSetting')}
|
|
406
|
+
</Button>
|
|
407
|
+
</form>
|
|
408
|
+
</div>
|
|
409
|
+
|
|
410
|
+
<ConfirmDialog {...dialogState} />
|
|
411
|
+
</div>
|
|
412
|
+
);
|
|
413
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import api from '../../api/client';
|
|
4
|
+
import ConfirmDialog from '../ui/ConfirmDialog';
|
|
5
|
+
import { useConfirmDialog } from '../../hooks/useConfirmDialog';
|
|
6
|
+
import { Plus, Edit, Trash2, Users, Network } from 'lucide-react';
|
|
7
|
+
import TeamModal from '../modals/TeamModal';
|
|
8
|
+
import TeamAssignmentModal from '../modals/TeamAssignmentModal';
|
|
9
|
+
import Button from '../ui/Button';
|
|
10
|
+
import { PageLoadingSkeleton } from '../ui/PageLoadingSkeleton';
|
|
11
|
+
|
|
12
|
+
interface Team {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
description: string | null;
|
|
16
|
+
created_at: string;
|
|
17
|
+
members?: Array<{ id: string; name: string; email: string }>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default function AdminTeams() {
|
|
21
|
+
const { t } = useTranslation();
|
|
22
|
+
const { showConfirm, dialogState } = useConfirmDialog();
|
|
23
|
+
const [teams, setTeams] = useState<Team[]>([]);
|
|
24
|
+
const [loading, setLoading] = useState(true);
|
|
25
|
+
const [modalOpen, setModalOpen] = useState(false);
|
|
26
|
+
const [assignmentModalOpen, setAssignmentModalOpen] = useState(false);
|
|
27
|
+
const [editingTeam, setEditingTeam] = useState<Team | null>(null);
|
|
28
|
+
const [selectedTeamForAssignment, setSelectedTeamForAssignment] = useState<Team | null>(null);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
loadTeams();
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
const loadTeams = async () => {
|
|
35
|
+
try {
|
|
36
|
+
const response = await api.get('/admin/teams');
|
|
37
|
+
setTeams(response.data);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.error('Failed to load teams:', error);
|
|
40
|
+
} finally {
|
|
41
|
+
setLoading(false);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const handleEdit = (team: Team) => {
|
|
46
|
+
setEditingTeam(team);
|
|
47
|
+
setModalOpen(true);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const handleManageMembers = (team: Team) => {
|
|
51
|
+
setSelectedTeamForAssignment(team);
|
|
52
|
+
setAssignmentModalOpen(true);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const handleDelete = (id: string) => {
|
|
56
|
+
showConfirm(
|
|
57
|
+
t('admin.confirmDeleteTeam'),
|
|
58
|
+
t('admin.confirmDeleteTeam'),
|
|
59
|
+
async () => {
|
|
60
|
+
try {
|
|
61
|
+
await api.delete(`/admin/teams/${id}`);
|
|
62
|
+
loadTeams();
|
|
63
|
+
} catch (error: any) {
|
|
64
|
+
alert(error.response?.data?.error || t('common.error'));
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
{ variant: 'danger', confirmText: t('common.delete'), cancelText: t('common.cancel') }
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const handleModalClose = () => {
|
|
72
|
+
setModalOpen(false);
|
|
73
|
+
setEditingTeam(null);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const handleAssignmentModalClose = () => {
|
|
77
|
+
setAssignmentModalOpen(false);
|
|
78
|
+
setSelectedTeamForAssignment(null);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
if (loading) {
|
|
82
|
+
return <PageLoadingSkeleton lines={6} />;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div className="space-y-6">
|
|
87
|
+
<div className="flex items-center justify-between">
|
|
88
|
+
<div>
|
|
89
|
+
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('admin.teams')}</h2>
|
|
90
|
+
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
91
|
+
{teams.length} {teams.length === 1 ? 'team' : 'teams'}
|
|
92
|
+
</p>
|
|
93
|
+
</div>
|
|
94
|
+
<Button onClick={() => setModalOpen(true)} icon={Plus}>
|
|
95
|
+
{t('admin.addTeam')}
|
|
96
|
+
</Button>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
{teams.length === 0 ? (
|
|
100
|
+
<div className="flex flex-col items-center justify-center py-16 px-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
|
101
|
+
<Users className="h-12 w-12 text-gray-400 dark:text-gray-500 mb-4" />
|
|
102
|
+
<p className="text-gray-500 dark:text-gray-400 text-lg mb-4">{t('admin.noTeamsYet')}</p>
|
|
103
|
+
<Button onClick={() => setModalOpen(true)} variant="primary" size="sm" icon={Plus}>
|
|
104
|
+
{t('admin.addTeam')}
|
|
105
|
+
</Button>
|
|
106
|
+
</div>
|
|
107
|
+
) : (
|
|
108
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
109
|
+
{teams.map((team) => (
|
|
110
|
+
<div
|
|
111
|
+
key={team.id}
|
|
112
|
+
className="group bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-all overflow-hidden"
|
|
113
|
+
>
|
|
114
|
+
<div className="p-4 space-y-4">
|
|
115
|
+
<div className="flex items-start justify-between gap-3">
|
|
116
|
+
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
117
|
+
<Users className="h-5 w-5 text-gray-400 dark:text-gray-500 flex-shrink-0" />
|
|
118
|
+
<h3 className="text-lg font-semibold text-gray-900 dark:text-white truncate">
|
|
119
|
+
{team.name}
|
|
120
|
+
</h3>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
{team.description && (
|
|
124
|
+
<p className="text-sm text-gray-500 dark:text-gray-400">{team.description}</p>
|
|
125
|
+
)}
|
|
126
|
+
<div className="flex gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
|
127
|
+
<Button
|
|
128
|
+
variant="ghost"
|
|
129
|
+
size="sm"
|
|
130
|
+
icon={Network}
|
|
131
|
+
onClick={() => handleManageMembers(team)}
|
|
132
|
+
className="flex-1"
|
|
133
|
+
title={t('admin.manageMembers')}
|
|
134
|
+
>
|
|
135
|
+
{t('admin.members')}
|
|
136
|
+
</Button>
|
|
137
|
+
<Button
|
|
138
|
+
variant="ghost"
|
|
139
|
+
size="sm"
|
|
140
|
+
icon={Edit}
|
|
141
|
+
onClick={() => handleEdit(team)}
|
|
142
|
+
/>
|
|
143
|
+
<Button
|
|
144
|
+
variant="danger"
|
|
145
|
+
size="sm"
|
|
146
|
+
icon={Trash2}
|
|
147
|
+
onClick={() => handleDelete(team.id)}
|
|
148
|
+
/>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
))}
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
|
|
156
|
+
<TeamModal
|
|
157
|
+
team={editingTeam}
|
|
158
|
+
isOpen={modalOpen}
|
|
159
|
+
onClose={handleModalClose}
|
|
160
|
+
onSuccess={loadTeams}
|
|
161
|
+
/>
|
|
162
|
+
|
|
163
|
+
{selectedTeamForAssignment && (
|
|
164
|
+
<TeamAssignmentModal
|
|
165
|
+
mode="team"
|
|
166
|
+
teamId={selectedTeamForAssignment.id}
|
|
167
|
+
teamName={selectedTeamForAssignment.name}
|
|
168
|
+
isOpen={assignmentModalOpen}
|
|
169
|
+
onClose={handleAssignmentModalClose}
|
|
170
|
+
onSuccess={loadTeams}
|
|
171
|
+
/>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
<ConfirmDialog {...dialogState} />
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|