@authagonal/login 0.1.97
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/README.md +348 -0
- package/dist/App.d.ts +1 -0
- package/dist/api.d.ts +35 -0
- package/dist/branding.d.ts +22 -0
- package/dist/branding.json +8 -0
- package/dist/components/AuthLayout.d.ts +7 -0
- package/dist/components/ui/alert.d.ts +9 -0
- package/dist/components/ui/button.d.ts +11 -0
- package/dist/components/ui/card.d.ts +8 -0
- package/dist/components/ui/input.d.ts +3 -0
- package/dist/components/ui/label.d.ts +3 -0
- package/dist/components/ui/separator.d.ts +6 -0
- package/dist/favicon.svg +1 -0
- package/dist/hooks/useDarkMode.d.ts +6 -0
- package/dist/i18n/index.d.ts +2 -0
- package/dist/icons.svg +24 -0
- package/dist/index.css +3 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +6332 -0
- package/dist/lib/utils.d.ts +2 -0
- package/dist/main.d.ts +2 -0
- package/dist/pages/ConsentPage.d.ts +1 -0
- package/dist/pages/DevicePage.d.ts +1 -0
- package/dist/pages/ForgotPasswordPage.d.ts +1 -0
- package/dist/pages/GrantsPage.d.ts +1 -0
- package/dist/pages/LoginPage.d.ts +1 -0
- package/dist/pages/MfaChallengePage.d.ts +1 -0
- package/dist/pages/MfaSetupPage.d.ts +1 -0
- package/dist/pages/RegisterPage.d.ts +1 -0
- package/dist/pages/ResetPasswordPage.d.ts +1 -0
- package/dist/types.d.ts +91 -0
- package/index.html +13 -0
- package/package.json +65 -0
- package/public/branding.json +8 -0
- package/public/favicon.svg +1 -0
- package/public/icons.svg +24 -0
- package/src/App.tsx +32 -0
- package/src/api.ts +156 -0
- package/src/branding.ts +55 -0
- package/src/components/AuthLayout.tsx +107 -0
- package/src/components/ui/alert.tsx +31 -0
- package/src/components/ui/button.tsx +51 -0
- package/src/components/ui/card.tsx +50 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +17 -0
- package/src/components/ui/separator.tsx +16 -0
- package/src/hooks/useDarkMode.ts +39 -0
- package/src/i18n/de.json +111 -0
- package/src/i18n/en.json +136 -0
- package/src/i18n/es.json +111 -0
- package/src/i18n/fr.json +111 -0
- package/src/i18n/index.ts +39 -0
- package/src/i18n/pt.json +111 -0
- package/src/i18n/tlh.json +111 -0
- package/src/i18n/vi.json +111 -0
- package/src/i18n/zh-Hans.json +111 -0
- package/src/index.ts +44 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.tsx +19 -0
- package/src/pages/ConsentPage.tsx +144 -0
- package/src/pages/DevicePage.tsx +145 -0
- package/src/pages/ForgotPasswordPage.tsx +90 -0
- package/src/pages/GrantsPage.tsx +87 -0
- package/src/pages/LoginPage.tsx +423 -0
- package/src/pages/MfaChallengePage.tsx +246 -0
- package/src/pages/MfaSetupPage.tsx +366 -0
- package/src/pages/RegisterPage.tsx +161 -0
- package/src/pages/ResetPasswordPage.tsx +219 -0
- package/src/styles.css +33 -0
- package/src/types.ts +112 -0
- package/tsconfig.app.json +37 -0
- package/tsconfig.json +7 -0
- package/vite.config.ts +54 -0
package/src/lib/utils.ts
ADDED
package/src/main.tsx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { StrictMode } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import './styles.css';
|
|
4
|
+
import App from './App';
|
|
5
|
+
import { loadBranding, BrandingContext } from './branding';
|
|
6
|
+
import './i18n';
|
|
7
|
+
import i18n from './i18n';
|
|
8
|
+
|
|
9
|
+
loadBranding().then((config) => {
|
|
10
|
+
document.title = i18n.t('signInTitle', { appName: config.appName });
|
|
11
|
+
|
|
12
|
+
createRoot(document.getElementById('root')!).render(
|
|
13
|
+
<StrictMode>
|
|
14
|
+
<BrandingContext.Provider value={config}>
|
|
15
|
+
<App />
|
|
16
|
+
</BrandingContext.Provider>
|
|
17
|
+
</StrictMode>,
|
|
18
|
+
);
|
|
19
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { useSearchParams } from 'react-router-dom';
|
|
4
|
+
import AuthLayout from '../components/AuthLayout';
|
|
5
|
+
import { CardTitle, CardFooter } from '../components/ui/card';
|
|
6
|
+
import { Button } from '../components/ui/button';
|
|
7
|
+
import { Alert } from '../components/ui/alert';
|
|
8
|
+
|
|
9
|
+
const SCOPE_LABELS: Record<string, string> = {
|
|
10
|
+
openid: 'consent.scopeOpenid',
|
|
11
|
+
profile: 'consent.scopeProfile',
|
|
12
|
+
email: 'consent.scopeEmail',
|
|
13
|
+
offline_access: 'consent.scopeOfflineAccess',
|
|
14
|
+
address: 'consent.scopeAddress',
|
|
15
|
+
phone: 'consent.scopePhone',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
interface ConsentInfo {
|
|
19
|
+
clientId: string;
|
|
20
|
+
clientName: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
clientUri?: string;
|
|
23
|
+
logoUri?: string;
|
|
24
|
+
scopes: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default function ConsentPage() {
|
|
28
|
+
const { t } = useTranslation();
|
|
29
|
+
const [searchParams] = useSearchParams();
|
|
30
|
+
const clientId = searchParams.get('client_id') ?? '';
|
|
31
|
+
const scope = searchParams.get('scope') ?? 'openid';
|
|
32
|
+
const returnUrl = searchParams.get('returnUrl') ?? '/';
|
|
33
|
+
|
|
34
|
+
const [info, setInfo] = useState<ConsentInfo | null>(null);
|
|
35
|
+
const [loading, setLoading] = useState(true);
|
|
36
|
+
const [submitting, setSubmitting] = useState(false);
|
|
37
|
+
const [error, setError] = useState('');
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
fetch(`/consent/info?client_id=${encodeURIComponent(clientId)}&scope=${encodeURIComponent(scope)}`)
|
|
41
|
+
.then(async (res) => {
|
|
42
|
+
if (!res.ok) throw new Error('Failed to load');
|
|
43
|
+
setInfo(await res.json());
|
|
44
|
+
})
|
|
45
|
+
.catch(() => setError(t('consent.loadError')))
|
|
46
|
+
.finally(() => setLoading(false));
|
|
47
|
+
}, [clientId, scope, t]);
|
|
48
|
+
|
|
49
|
+
async function handleDecision(decision: 'allow' | 'deny') {
|
|
50
|
+
setSubmitting(true);
|
|
51
|
+
setError('');
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch('/consent', {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'Content-Type': 'application/json' },
|
|
56
|
+
body: JSON.stringify({
|
|
57
|
+
clientId,
|
|
58
|
+
decision,
|
|
59
|
+
scopes: info?.scopes ?? scope.split(' '),
|
|
60
|
+
returnUrl,
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
const data = await res.json();
|
|
64
|
+
if (data.redirect) {
|
|
65
|
+
window.location.href = data.redirect;
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
setError(t('consent.submitError'));
|
|
69
|
+
setSubmitting(false);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (loading) {
|
|
74
|
+
return (
|
|
75
|
+
<AuthLayout>
|
|
76
|
+
<p className="text-sm text-gray-500 dark:text-gray-400 text-center">{t('consent.loading')}</p>
|
|
77
|
+
</AuthLayout>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<AuthLayout>
|
|
83
|
+
{info?.logoUri && (
|
|
84
|
+
<div className="flex justify-center mb-4">
|
|
85
|
+
<img
|
|
86
|
+
src={info.logoUri}
|
|
87
|
+
alt={info.clientName}
|
|
88
|
+
className="h-12 w-12 rounded-lg object-contain"
|
|
89
|
+
onError={(e) => {
|
|
90
|
+
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
|
91
|
+
}}
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
<CardTitle>{t('consent.title', { appName: info?.clientName ?? clientId })}</CardTitle>
|
|
96
|
+
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">{t('consent.subtitle', { appName: info?.clientName ?? clientId })}</p>
|
|
97
|
+
|
|
98
|
+
{info?.description && (
|
|
99
|
+
<p className="text-sm text-gray-600 dark:text-gray-300 mb-4">{info.description}</p>
|
|
100
|
+
)}
|
|
101
|
+
{info?.clientUri && (
|
|
102
|
+
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
|
103
|
+
<a
|
|
104
|
+
href={info.clientUri}
|
|
105
|
+
target="_blank"
|
|
106
|
+
rel="noopener noreferrer"
|
|
107
|
+
className="text-primary hover:underline"
|
|
108
|
+
>
|
|
109
|
+
{info.clientUri}
|
|
110
|
+
</a>
|
|
111
|
+
</p>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
{error && <Alert variant="error">{error}</Alert>}
|
|
115
|
+
|
|
116
|
+
<div className="space-y-2 mb-6">
|
|
117
|
+
{(info?.scopes ?? scope.split(' ')).map((s) => {
|
|
118
|
+
const labelKey = SCOPE_LABELS[s];
|
|
119
|
+
return (
|
|
120
|
+
<div key={s} className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800/60 rounded-lg">
|
|
121
|
+
<div className="w-2 h-2 bg-primary rounded-full shrink-0" />
|
|
122
|
+
<span className="text-sm text-gray-700 dark:text-gray-300">
|
|
123
|
+
{labelKey ? t(labelKey) : s}
|
|
124
|
+
</span>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
})}
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div className="flex gap-3">
|
|
131
|
+
<Button onClick={() => handleDecision('allow')} loading={submitting} className="flex-1">
|
|
132
|
+
{t('consent.allow')}
|
|
133
|
+
</Button>
|
|
134
|
+
<Button variant="secondary" onClick={() => handleDecision('deny')} disabled={submitting} className="flex-1">
|
|
135
|
+
{t('consent.deny')}
|
|
136
|
+
</Button>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<CardFooter>
|
|
140
|
+
<p className="text-xs text-gray-400 dark:text-gray-500">{t('consent.hint')}</p>
|
|
141
|
+
</CardFooter>
|
|
142
|
+
</AuthLayout>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { useSearchParams, useNavigate } from 'react-router-dom';
|
|
3
|
+
import { getSession } from '../api';
|
|
4
|
+
import { Button } from '@/components/ui/button';
|
|
5
|
+
import { Input } from '@/components/ui/input';
|
|
6
|
+
import { Label } from '@/components/ui/label';
|
|
7
|
+
import { Alert } from '@/components/ui/alert';
|
|
8
|
+
import { CardTitle } from '@/components/ui/card';
|
|
9
|
+
|
|
10
|
+
const API_URL = import.meta.env.VITE_API_URL || '';
|
|
11
|
+
|
|
12
|
+
export default function DevicePage() {
|
|
13
|
+
const navigate = useNavigate();
|
|
14
|
+
const [searchParams] = useSearchParams();
|
|
15
|
+
const [userCode, setUserCode] = useState(searchParams.get('user_code') || '');
|
|
16
|
+
const [loading, setLoading] = useState(false);
|
|
17
|
+
const [checking, setChecking] = useState(true);
|
|
18
|
+
const [authenticated, setAuthenticated] = useState(false);
|
|
19
|
+
const [approved, setApproved] = useState(false);
|
|
20
|
+
const [error, setError] = useState('');
|
|
21
|
+
|
|
22
|
+
// Check if user is already authenticated
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
getSession()
|
|
25
|
+
.then((session) => {
|
|
26
|
+
setAuthenticated(!!session?.userId);
|
|
27
|
+
})
|
|
28
|
+
.catch(() => setAuthenticated(false))
|
|
29
|
+
.finally(() => setChecking(false));
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
async function handleApprove(e: React.FormEvent) {
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
setLoading(true);
|
|
35
|
+
setError('');
|
|
36
|
+
|
|
37
|
+
const code = userCode.trim().toUpperCase();
|
|
38
|
+
if (!code) {
|
|
39
|
+
setError('Please enter the code shown on your device.');
|
|
40
|
+
setLoading(false);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const res = await fetch(`${API_URL}/api/auth/device/approve`, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
48
|
+
credentials: 'include',
|
|
49
|
+
body: `user_code=${encodeURIComponent(code)}`,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (res.ok) {
|
|
53
|
+
setApproved(true);
|
|
54
|
+
} else {
|
|
55
|
+
const body = await res.json().catch(() => ({}));
|
|
56
|
+
if (body.error === 'invalid_user_code') {
|
|
57
|
+
setError('Invalid or expired code. Check the code on your device and try again.');
|
|
58
|
+
} else {
|
|
59
|
+
setError(body.message || 'Failed to approve. Please try again.');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
setError('Something went wrong. Please try again.');
|
|
64
|
+
} finally {
|
|
65
|
+
setLoading(false);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (checking) {
|
|
70
|
+
return <p className="text-sm text-gray-500 dark:text-gray-400 text-center">Loading...</p>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Not authenticated — redirect to login with returnUrl back to this page
|
|
74
|
+
if (!authenticated) {
|
|
75
|
+
const returnUrl = userCode
|
|
76
|
+
? `/device?user_code=${encodeURIComponent(userCode)}`
|
|
77
|
+
: '/device';
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className="text-center">
|
|
81
|
+
<CardTitle className="mb-4">Sign in to continue</CardTitle>
|
|
82
|
+
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
|
83
|
+
Sign in to approve access for your device.
|
|
84
|
+
</p>
|
|
85
|
+
<Button
|
|
86
|
+
className="w-full"
|
|
87
|
+
onClick={() => navigate(`/login?returnUrl=${encodeURIComponent(returnUrl)}`)}
|
|
88
|
+
>
|
|
89
|
+
Sign In
|
|
90
|
+
</Button>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Approved
|
|
96
|
+
if (approved) {
|
|
97
|
+
return (
|
|
98
|
+
<div className="text-center">
|
|
99
|
+
<CardTitle className="mb-4">Device approved</CardTitle>
|
|
100
|
+
<div className="flex justify-center mb-4">
|
|
101
|
+
<div className="w-16 h-16 rounded-full bg-green-100 dark:bg-green-900/40 flex items-center justify-center">
|
|
102
|
+
<svg className="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
103
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
104
|
+
</svg>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
108
|
+
You can close this window. Your device should be signed in momentarily.
|
|
109
|
+
</p>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Enter code form
|
|
115
|
+
return (
|
|
116
|
+
<div>
|
|
117
|
+
<CardTitle className="mb-2 text-center">Authorize device</CardTitle>
|
|
118
|
+
<p className="text-sm text-gray-500 dark:text-gray-400 text-center mb-6">
|
|
119
|
+
Enter the code displayed on your device.
|
|
120
|
+
</p>
|
|
121
|
+
|
|
122
|
+
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
|
|
123
|
+
|
|
124
|
+
<form onSubmit={handleApprove}>
|
|
125
|
+
<div className="mb-4">
|
|
126
|
+
<Label htmlFor="user_code">Device code</Label>
|
|
127
|
+
<Input
|
|
128
|
+
id="user_code"
|
|
129
|
+
type="text"
|
|
130
|
+
value={userCode}
|
|
131
|
+
onChange={(e) => setUserCode(e.target.value.toUpperCase())}
|
|
132
|
+
placeholder="ABCD-1234"
|
|
133
|
+
className="text-center text-2xl font-mono tracking-widest"
|
|
134
|
+
maxLength={9}
|
|
135
|
+
autoFocus
|
|
136
|
+
autoComplete="off"
|
|
137
|
+
/>
|
|
138
|
+
</div>
|
|
139
|
+
<Button type="submit" className="w-full" loading={loading}>
|
|
140
|
+
Approve
|
|
141
|
+
</Button>
|
|
142
|
+
</form>
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useSearchParams, Link } from 'react-router-dom';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { forgotPassword } from '../api';
|
|
5
|
+
import { Button } from '@/components/ui/button';
|
|
6
|
+
import { Input } from '@/components/ui/input';
|
|
7
|
+
import { Label } from '@/components/ui/label';
|
|
8
|
+
import { Alert } from '@/components/ui/alert';
|
|
9
|
+
import { CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
|
|
10
|
+
|
|
11
|
+
export default function ForgotPasswordPage() {
|
|
12
|
+
const { t } = useTranslation();
|
|
13
|
+
const [searchParams] = useSearchParams();
|
|
14
|
+
const returnUrl = searchParams.get('returnUrl') || '';
|
|
15
|
+
|
|
16
|
+
const [email, setEmail] = useState('');
|
|
17
|
+
const [loading, setLoading] = useState(false);
|
|
18
|
+
const [submitted, setSubmitted] = useState(false);
|
|
19
|
+
const [error, setError] = useState('');
|
|
20
|
+
|
|
21
|
+
const loginLink = returnUrl
|
|
22
|
+
? `/login?returnUrl=${encodeURIComponent(returnUrl)}`
|
|
23
|
+
: '/login';
|
|
24
|
+
|
|
25
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
26
|
+
e.preventDefault();
|
|
27
|
+
setError('');
|
|
28
|
+
setLoading(true);
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
await forgotPassword(email);
|
|
32
|
+
setSubmitted(true);
|
|
33
|
+
} catch {
|
|
34
|
+
// The API always returns 200 for anti-enumeration, but handle errors just in case
|
|
35
|
+
setError(t('errorUnexpected'));
|
|
36
|
+
} finally {
|
|
37
|
+
setLoading(false);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (submitted) {
|
|
42
|
+
return (
|
|
43
|
+
<div>
|
|
44
|
+
<CardTitle>{t('checkYourEmail')}</CardTitle>
|
|
45
|
+
<Alert variant="success">{t('resetEmailSent')}</Alert>
|
|
46
|
+
<CardFooter>
|
|
47
|
+
<Link to={loginLink} className="text-sm font-medium text-primary hover:underline no-underline">
|
|
48
|
+
{t('backToSignIn')}
|
|
49
|
+
</Link>
|
|
50
|
+
</CardFooter>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div>
|
|
57
|
+
<CardTitle>{t('resetYourPassword')}</CardTitle>
|
|
58
|
+
<CardDescription className="mb-5">{t('resetSubtitle')}</CardDescription>
|
|
59
|
+
|
|
60
|
+
{error && <Alert variant="error">{error}</Alert>}
|
|
61
|
+
|
|
62
|
+
<form onSubmit={handleSubmit}>
|
|
63
|
+
<div className="mb-4">
|
|
64
|
+
<Label htmlFor="email">{t('email')}</Label>
|
|
65
|
+
<Input
|
|
66
|
+
id="email"
|
|
67
|
+
type="email"
|
|
68
|
+
value={email}
|
|
69
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
70
|
+
placeholder={t('emailPlaceholder')}
|
|
71
|
+
autoComplete="email"
|
|
72
|
+
autoFocus
|
|
73
|
+
maxLength={256}
|
|
74
|
+
required
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<Button type="submit" loading={loading}>
|
|
79
|
+
{loading ? t('sending') : t('sendResetLink')}
|
|
80
|
+
</Button>
|
|
81
|
+
|
|
82
|
+
<CardFooter>
|
|
83
|
+
<Link to={loginLink} className="text-sm font-medium text-primary hover:underline no-underline">
|
|
84
|
+
{t('backToSignIn')}
|
|
85
|
+
</Link>
|
|
86
|
+
</CardFooter>
|
|
87
|
+
</form>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import AuthLayout from '../components/AuthLayout';
|
|
4
|
+
import { CardTitle } from '../components/ui/card';
|
|
5
|
+
import { Button } from '../components/ui/button';
|
|
6
|
+
import { Alert } from '../components/ui/alert';
|
|
7
|
+
|
|
8
|
+
interface ConsentGrant {
|
|
9
|
+
clientId: string;
|
|
10
|
+
clientName: string;
|
|
11
|
+
scopes: string[];
|
|
12
|
+
consentedAt: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function GrantsPage() {
|
|
16
|
+
const { t } = useTranslation();
|
|
17
|
+
const [grants, setGrants] = useState<ConsentGrant[]>([]);
|
|
18
|
+
const [loading, setLoading] = useState(true);
|
|
19
|
+
const [error, setError] = useState('');
|
|
20
|
+
const [revoking, setRevoking] = useState('');
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
fetch('/consent/grants')
|
|
24
|
+
.then(async (res) => {
|
|
25
|
+
if (!res.ok) throw new Error();
|
|
26
|
+
setGrants(await res.json());
|
|
27
|
+
})
|
|
28
|
+
.catch(() => setError(t('grants.loadError')))
|
|
29
|
+
.finally(() => setLoading(false));
|
|
30
|
+
}, [t]);
|
|
31
|
+
|
|
32
|
+
async function handleRevoke(clientId: string) {
|
|
33
|
+
if (!confirm(t('grants.revokeConfirm'))) return;
|
|
34
|
+
setRevoking(clientId);
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch(`/consent/grants/${encodeURIComponent(clientId)}`, { method: 'DELETE' });
|
|
37
|
+
if (res.ok) {
|
|
38
|
+
setGrants(g => g.filter(x => x.clientId !== clientId));
|
|
39
|
+
} else {
|
|
40
|
+
setError(t('grants.revokeFailed'));
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
setError(t('grants.revokeFailed'));
|
|
44
|
+
} finally {
|
|
45
|
+
setRevoking('');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<AuthLayout>
|
|
51
|
+
<CardTitle>{t('grants.title')}</CardTitle>
|
|
52
|
+
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">{t('grants.subtitle')}</p>
|
|
53
|
+
|
|
54
|
+
{error && <Alert variant="error">{error}</Alert>}
|
|
55
|
+
|
|
56
|
+
{loading ? (
|
|
57
|
+
<p className="text-sm text-gray-500 dark:text-gray-400">{t('grants.loading')}</p>
|
|
58
|
+
) : grants.length === 0 ? (
|
|
59
|
+
<p className="text-sm text-gray-500 dark:text-gray-400">{t('grants.noGrants')}</p>
|
|
60
|
+
) : (
|
|
61
|
+
<div className="space-y-3">
|
|
62
|
+
{grants.map((g) => (
|
|
63
|
+
<div key={g.clientId} className="flex items-start justify-between p-3 bg-gray-50 dark:bg-gray-800/60 rounded-lg">
|
|
64
|
+
<div>
|
|
65
|
+
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{g.clientName}</p>
|
|
66
|
+
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
67
|
+
{g.scopes.join(', ')}
|
|
68
|
+
</p>
|
|
69
|
+
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
|
70
|
+
{t('grants.grantedOn', { date: new Date(g.consentedAt).toLocaleDateString() })}
|
|
71
|
+
</p>
|
|
72
|
+
</div>
|
|
73
|
+
<Button
|
|
74
|
+
variant="secondary"
|
|
75
|
+
size="sm"
|
|
76
|
+
loading={revoking === g.clientId}
|
|
77
|
+
onClick={() => handleRevoke(g.clientId)}
|
|
78
|
+
>
|
|
79
|
+
{t('grants.revoke')}
|
|
80
|
+
</Button>
|
|
81
|
+
</div>
|
|
82
|
+
))}
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
</AuthLayout>
|
|
86
|
+
);
|
|
87
|
+
}
|