@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,134 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Link } from 'react-router-dom';
4
+ import { ArrowLeft, Trash2, ExternalLink } from 'lucide-react';
5
+ import Button from '../components/ui/Button';
6
+ import { useToast } from '../components/ui/Toast';
7
+ import api from '../api/client';
8
+ import { useAppConfig } from '../contexts/AppConfigContext';
9
+ import { PageLoadingSkeleton } from '../components/ui/PageLoadingSkeleton';
10
+ import { safeHref } from '../utils/safeHref';
11
+
12
+ interface SlugPreference {
13
+ slug: string;
14
+ bookmark_id: string;
15
+ title: string;
16
+ url: string;
17
+ workspace: string;
18
+ created_at: string;
19
+ updated_at: string;
20
+ }
21
+
22
+ export default function GoPreferences() {
23
+ const { t } = useTranslation();
24
+ const { appBasePath } = useAppConfig();
25
+ const { showToast } = useToast();
26
+ const [preferences, setPreferences] = useState<SlugPreference[]>([]);
27
+ const [loading, setLoading] = useState(true);
28
+ const [deleting, setDeleting] = useState<string | null>(null);
29
+
30
+ useEffect(() => {
31
+ loadPreferences();
32
+ }, []);
33
+
34
+ async function loadPreferences() {
35
+ try {
36
+ const res = await api.get('/go/preferences');
37
+ setPreferences(res.data);
38
+ } catch (err) {
39
+ console.error('Failed to load preferences:', err);
40
+ showToast(t('common.error'), 'error');
41
+ } finally {
42
+ setLoading(false);
43
+ }
44
+ }
45
+
46
+ async function handleRemove(slug: string) {
47
+ setDeleting(slug);
48
+ try {
49
+ await api.delete(`/go/preferences/${encodeURIComponent(slug)}`);
50
+ setPreferences((prev) => prev.filter((p) => p.slug !== slug));
51
+ showToast(t('common.success'), 'success');
52
+ } catch (err) {
53
+ console.error('Failed to remove preference:', err);
54
+ showToast(t('common.error'), 'error');
55
+ } finally {
56
+ setDeleting(null);
57
+ }
58
+ }
59
+
60
+ if (loading) {
61
+ return <PageLoadingSkeleton lines={6} />;
62
+ }
63
+
64
+ return (
65
+ <div className="space-y-6 max-w-3xl">
66
+ <div className="flex items-center gap-4">
67
+ <Link to={`${appBasePath}/profile`}>
68
+ <Button variant="ghost" size="sm" icon={ArrowLeft}>
69
+ {t('common.back')}
70
+ </Button>
71
+ </Link>
72
+ <div>
73
+ <h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
74
+ {t('goPreferences.title')}
75
+ </h1>
76
+ <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
77
+ {t('goPreferences.description')}
78
+ </p>
79
+ </div>
80
+ </div>
81
+
82
+ <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
83
+ {preferences.length === 0 ? (
84
+ <div className="p-8 text-center text-gray-500 dark:text-gray-400">
85
+ {t('goPreferences.empty')}
86
+ </div>
87
+ ) : (
88
+ <ul className="divide-y divide-gray-200 dark:divide-gray-700">
89
+ {preferences.map((pref) => (
90
+ <li
91
+ key={pref.slug}
92
+ className="flex items-center justify-between gap-4 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50"
93
+ >
94
+ <div className="min-w-0 flex-1">
95
+ <div className="flex items-center gap-2">
96
+ <code className="text-sm font-mono text-blue-600 dark:text-blue-400">
97
+ /go/{pref.slug}
98
+ </code>
99
+ <span className="text-xs px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
100
+ {pref.workspace}
101
+ </span>
102
+ </div>
103
+ <p className="mt-1 text-sm text-gray-600 dark:text-gray-400 truncate">
104
+ {pref.title}
105
+ </p>
106
+ </div>
107
+ <div className="flex items-center gap-2 shrink-0">
108
+ <a
109
+ href={safeHref(pref.url)}
110
+ target="_blank"
111
+ rel="noopener noreferrer"
112
+ className="p-2 text-gray-500 hover:text-blue-600 dark:hover:text-blue-400"
113
+ title={t('common.open')}
114
+ >
115
+ <ExternalLink className="h-4 w-4" />
116
+ </a>
117
+ <Button
118
+ variant="ghost"
119
+ size="sm"
120
+ icon={Trash2}
121
+ onClick={() => handleRemove(pref.slug)}
122
+ disabled={deleting === pref.slug}
123
+ className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
124
+ title={t('common.remove')}
125
+ />
126
+ </div>
127
+ </li>
128
+ ))}
129
+ </ul>
130
+ )}
131
+ </div>
132
+ </div>
133
+ );
134
+ }
@@ -0,0 +1,196 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { useNavigate, Link, useSearchParams } from 'react-router-dom';
4
+ import { useAuth } from '../contexts/AuthContext';
5
+ import api from '../api/client';
6
+ import { getAuthProviderUrl } from '../config/api';
7
+ import { LogIn, Key } from 'lucide-react';
8
+ import Button from '../components/ui/Button';
9
+
10
+ export default function Login() {
11
+ const { t } = useTranslation();
12
+ const navigate = useNavigate();
13
+ const [searchParams, setSearchParams] = useSearchParams();
14
+ const { user } = useAuth();
15
+ const [providers, setProviders] = useState<any[]>([]);
16
+ const [loading, setLoading] = useState(true);
17
+ const [localAuth, setLocalAuth] = useState({
18
+ email: '',
19
+ password: '',
20
+ });
21
+ const [localLoading, setLocalLoading] = useState(false);
22
+ const [error, setError] = useState('');
23
+
24
+ useEffect(() => {
25
+ if (user) {
26
+ const redirectTo = searchParams.get('redirect');
27
+ const safePath = redirectTo?.startsWith('/') && !redirectTo.startsWith('//') ? redirectTo : null;
28
+ navigate(safePath || '/', { replace: true });
29
+ }
30
+ }, [user, navigate, searchParams]);
31
+
32
+ useEffect(() => {
33
+ // Check for OIDC error in URL query parameters
34
+ const errorParam = searchParams.get('error');
35
+ if (errorParam) {
36
+ if (errorParam === 'auth_failed') {
37
+ setError(t('auth.oidcAuthFailed'));
38
+ } else if (errorParam === 'auto_create_disabled') {
39
+ setError(t('auth.oidcAutoCreateDisabled'));
40
+ } else {
41
+ setError(t('auth.loginFailed'));
42
+ }
43
+ // Remove error from URL
44
+ searchParams.delete('error');
45
+ setSearchParams(searchParams, { replace: true });
46
+ }
47
+ }, [searchParams, setSearchParams, t]);
48
+
49
+ useEffect(() => {
50
+ api.get('/auth/providers')
51
+ .then(res => setProviders(res.data))
52
+ .catch(() => setProviders([]))
53
+ .finally(() => setLoading(false));
54
+ }, []);
55
+
56
+ const handleOIDCLogin = (providerKey: string) => {
57
+ window.location.href = getAuthProviderUrl(providerKey);
58
+ };
59
+
60
+ const handleLocalLogin = async (e: React.FormEvent) => {
61
+ e.preventDefault();
62
+ setLocalLoading(true);
63
+ setError('');
64
+
65
+ try {
66
+ await api.post('/auth/login', localAuth);
67
+ const redirectTo = searchParams.get('redirect');
68
+ const safePath = redirectTo?.startsWith('/') && !redirectTo.startsWith('//') ? redirectTo : null;
69
+ window.location.href = safePath || '/';
70
+ } catch (err: any) {
71
+ const code = err.response?.data?.code;
72
+ const message = err.response?.data?.error;
73
+ if (code === 'EMAIL_NOT_VERIFIED') {
74
+ setError(t('auth.verifyEmailRequired'));
75
+ } else {
76
+ setError(message || t('auth.loginFailed'));
77
+ }
78
+ } finally {
79
+ setLocalLoading(false);
80
+ }
81
+ };
82
+
83
+ return (
84
+ <div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
85
+ <div className="max-w-md w-full space-y-8">
86
+ <div className="text-center">
87
+ <div className="flex justify-center mb-6">
88
+ <img
89
+ src="/slugbase_icon_blue.svg"
90
+ alt="SlugBase"
91
+ className="h-16 w-16 dark:hidden"
92
+ />
93
+ <img
94
+ src="/slugbase_icon_white.svg"
95
+ alt="SlugBase"
96
+ className="h-16 w-16 hidden dark:block"
97
+ />
98
+ </div>
99
+ <h2 className="text-2xl font-semibold text-gray-900 dark:text-white">
100
+ {t('auth.login')}
101
+ </h2>
102
+ <p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
103
+ {t('app.tagline')}
104
+ </p>
105
+ </div>
106
+
107
+ <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-lg p-4 space-y-6">
108
+ {/* Local Authentication Form */}
109
+ <form onSubmit={handleLocalLogin} className="space-y-5">
110
+ <div>
111
+ <label htmlFor="email" className="block text-sm font-semibold text-gray-900 dark:text-white mb-2">
112
+ {t('auth.email')}
113
+ </label>
114
+ <input
115
+ id="email"
116
+ name="email"
117
+ type="email"
118
+ required
119
+ 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-ring focus:border-ring transition-colors"
120
+ placeholder={t('auth.emailPlaceholder')}
121
+ value={localAuth.email}
122
+ onChange={(e) => setLocalAuth({ ...localAuth, email: e.target.value })}
123
+ />
124
+ </div>
125
+ <div>
126
+ <label htmlFor="password" className="block text-sm font-semibold text-gray-900 dark:text-white mb-2">
127
+ {t('auth.password')}
128
+ </label>
129
+ <input
130
+ id="password"
131
+ name="password"
132
+ type="password"
133
+ required
134
+ 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-ring focus:border-ring transition-colors"
135
+ placeholder={t('auth.passwordPlaceholder')}
136
+ value={localAuth.password}
137
+ onChange={(e) => setLocalAuth({ ...localAuth, password: e.target.value })}
138
+ />
139
+ </div>
140
+ {error && (
141
+ <div className="px-4 py-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
142
+ <p className="text-sm text-red-800 dark:text-red-200">{error}</p>
143
+ </div>
144
+ )}
145
+ <Button
146
+ type="submit"
147
+ variant="primary"
148
+ disabled={localLoading}
149
+ icon={LogIn}
150
+ className="w-full"
151
+ >
152
+ {localLoading ? t('common.loading') : t('auth.login')}
153
+ </Button>
154
+ <div className="text-center space-y-2">
155
+ <Link
156
+ to="/password-reset"
157
+ className="block text-sm font-medium text-primary hover:text-primary/90"
158
+ >
159
+ {t('auth.forgotPassword')}
160
+ </Link>
161
+ </div>
162
+ </form>
163
+
164
+ {/* OIDC Providers */}
165
+ {!loading && providers.length > 0 && (
166
+ <>
167
+ <div className="relative">
168
+ <div className="absolute inset-0 flex items-center">
169
+ <div className="w-full border-t border-gray-300 dark:border-gray-600"></div>
170
+ </div>
171
+ <div className="relative flex justify-center text-sm">
172
+ <span className="px-3 bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400">
173
+ {t('auth.or')}
174
+ </span>
175
+ </div>
176
+ </div>
177
+ <div className="space-y-3">
178
+ {providers.map((provider) => (
179
+ <Button
180
+ key={provider.id}
181
+ variant="secondary"
182
+ icon={Key}
183
+ onClick={() => handleOIDCLogin(provider.provider_key)}
184
+ className="w-full"
185
+ >
186
+ {t('auth.loginWith', { provider: provider.provider_key })}
187
+ </Button>
188
+ ))}
189
+ </div>
190
+ </>
191
+ )}
192
+ </div>
193
+ </div>
194
+ </div>
195
+ );
196
+ }
@@ -0,0 +1,242 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { useSearchParams, useNavigate, Link } from 'react-router-dom';
4
+ import api from '../api/client';
5
+ import { useAppConfig } from '../contexts/AppConfigContext';
6
+ import { Mail, Key, ArrowLeft } from 'lucide-react';
7
+ import Button from '../components/ui/Button';
8
+
9
+ export default function PasswordReset() {
10
+ const { t } = useTranslation();
11
+ const { appBasePath } = useAppConfig();
12
+ const [searchParams] = useSearchParams();
13
+ const navigate = useNavigate();
14
+ const token = searchParams.get('token');
15
+
16
+ const step: 'request' | 'reset' = token ? 'reset' : 'request';
17
+ const [email, setEmail] = useState('');
18
+ const [password, setPassword] = useState('');
19
+ const [confirmPassword, setConfirmPassword] = useState('');
20
+ const [loading, setLoading] = useState(false);
21
+ const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
22
+ const [tokenValid, setTokenValid] = useState<boolean | null>(null);
23
+
24
+ useEffect(() => {
25
+ if (token) {
26
+ verifyToken();
27
+ }
28
+ }, [token]);
29
+
30
+ const verifyToken = async () => {
31
+ try {
32
+ const response = await api.get('/password-reset/verify', { params: { token } });
33
+ setTokenValid(response.data.valid);
34
+ } catch (error: any) {
35
+ setTokenValid(false);
36
+ setMessage({ type: 'error', text: t('passwordReset.invalidToken') });
37
+ }
38
+ };
39
+
40
+ const handleRequestReset = async (e: React.FormEvent) => {
41
+ e.preventDefault();
42
+ setLoading(true);
43
+ setMessage(null);
44
+
45
+ try {
46
+ await api.post('/password-reset/request', { email });
47
+ setMessage({ type: 'success', text: t('passwordReset.requestSent') });
48
+ setEmail('');
49
+ } catch (error: any) {
50
+ // Always show success message to prevent email enumeration
51
+ setMessage({ type: 'success', text: t('passwordReset.requestSent') });
52
+ setEmail('');
53
+ } finally {
54
+ setLoading(false);
55
+ }
56
+ };
57
+
58
+ const handleReset = async (e: React.FormEvent) => {
59
+ e.preventDefault();
60
+ setLoading(true);
61
+ setMessage(null);
62
+
63
+ if (password !== confirmPassword) {
64
+ setMessage({ type: 'error', text: t('passwordReset.passwordMismatch') });
65
+ setLoading(false);
66
+ return;
67
+ }
68
+
69
+ try {
70
+ await api.post('/password-reset/reset', { token, password });
71
+ setMessage({ type: 'success', text: t('passwordReset.resetSuccess') });
72
+ setTimeout(() => {
73
+ navigate('/login');
74
+ }, 2000);
75
+ } catch (error: any) {
76
+ setMessage({
77
+ type: 'error',
78
+ text: error.response?.data?.error || t('common.error'),
79
+ });
80
+ } finally {
81
+ setLoading(false);
82
+ }
83
+ };
84
+
85
+ return (
86
+ <div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
87
+ <div className="max-w-md w-full space-y-8">
88
+ <div>
89
+ <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
90
+ {t('passwordReset.title')}
91
+ </h2>
92
+ <p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
93
+ {step === 'request'
94
+ ? t('passwordReset.description')
95
+ : t('passwordReset.resetPassword')}
96
+ </p>
97
+ </div>
98
+
99
+ {message && (
100
+ <div
101
+ className={`rounded-lg p-4 ${
102
+ message.type === 'success'
103
+ ? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
104
+ : 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
105
+ }`}
106
+ >
107
+ {message.text}
108
+ </div>
109
+ )}
110
+
111
+ {step === 'request' ? (
112
+ <form className="mt-8 space-y-6" onSubmit={handleRequestReset}>
113
+ <div>
114
+ <label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
115
+ {t('passwordReset.email')}
116
+ </label>
117
+ <div className="relative">
118
+ <Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
119
+ <input
120
+ id="email"
121
+ name="email"
122
+ type="email"
123
+ autoComplete="email"
124
+ required
125
+ className="appearance-none relative block w-full pl-10 pr-3 py-3 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-700 rounded-lg focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
126
+ placeholder={t('passwordReset.emailPlaceholder')}
127
+ value={email}
128
+ onChange={(e) => setEmail(e.target.value)}
129
+ />
130
+ </div>
131
+ </div>
132
+
133
+ <div>
134
+ <Button
135
+ type="submit"
136
+ variant="primary"
137
+ className="w-full"
138
+ disabled={loading}
139
+ >
140
+ {t('passwordReset.requestReset')}
141
+ </Button>
142
+ </div>
143
+
144
+ <div className="text-center">
145
+ <Link
146
+ to={`${appBasePath}/login`}
147
+ className="inline-flex items-center gap-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
148
+ >
149
+ <ArrowLeft className="h-4 w-4" />
150
+ {t('passwordReset.backToLogin')}
151
+ </Link>
152
+ </div>
153
+ </form>
154
+ ) : (
155
+ <>
156
+ {tokenValid === null ? (
157
+ <div className="text-center text-gray-500 dark:text-gray-400">
158
+ {t('common.loading')}
159
+ </div>
160
+ ) : tokenValid === false ? (
161
+ <div className="text-center">
162
+ <p className="text-red-600 dark:text-red-400 mb-4">
163
+ {t('passwordReset.invalidToken')}
164
+ </p>
165
+ <Link
166
+ to={`${appBasePath}/password-reset`}
167
+ className="inline-flex items-center gap-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
168
+ >
169
+ <ArrowLeft className="h-4 w-4" />
170
+ {t('passwordReset.backToLogin')}
171
+ </Link>
172
+ </div>
173
+ ) : (
174
+ <form className="mt-8 space-y-6" onSubmit={handleReset}>
175
+ <div>
176
+ <label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
177
+ {t('passwordReset.newPassword')}
178
+ </label>
179
+ <div className="relative">
180
+ <Key className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
181
+ <input
182
+ id="password"
183
+ name="password"
184
+ type="password"
185
+ autoComplete="new-password"
186
+ required
187
+ className="appearance-none relative block w-full pl-10 pr-3 py-3 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-700 rounded-lg focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
188
+ placeholder={t('auth.passwordPlaceholder')}
189
+ value={password}
190
+ onChange={(e) => setPassword(e.target.value)}
191
+ />
192
+ </div>
193
+ </div>
194
+
195
+ <div>
196
+ <label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
197
+ {t('passwordReset.confirmPassword')}
198
+ </label>
199
+ <div className="relative">
200
+ <Key className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
201
+ <input
202
+ id="confirmPassword"
203
+ name="confirmPassword"
204
+ type="password"
205
+ autoComplete="new-password"
206
+ required
207
+ className="appearance-none relative block w-full pl-10 pr-3 py-3 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-700 rounded-lg focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
208
+ placeholder={t('passwordReset.confirmPassword')}
209
+ value={confirmPassword}
210
+ onChange={(e) => setConfirmPassword(e.target.value)}
211
+ />
212
+ </div>
213
+ </div>
214
+
215
+ <div>
216
+ <Button
217
+ type="submit"
218
+ variant="primary"
219
+ className="w-full"
220
+ disabled={loading}
221
+ >
222
+ {t('passwordReset.resetPassword')}
223
+ </Button>
224
+ </div>
225
+
226
+ <div className="text-center">
227
+ <Link
228
+ to={`${appBasePath}/login`}
229
+ className="inline-flex items-center gap-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
230
+ >
231
+ <ArrowLeft className="h-4 w-4" />
232
+ {t('passwordReset.backToLogin')}
233
+ </Link>
234
+ </div>
235
+ </form>
236
+ )}
237
+ </>
238
+ )}
239
+ </div>
240
+ </div>
241
+ );
242
+ }