@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,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
|
+
}
|