simple-module-users 0.0.1__py3-none-any.whl
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.
- simple_module_users-0.0.1.dist-info/METADATA +88 -0
- simple_module_users-0.0.1.dist-info/RECORD +56 -0
- simple_module_users-0.0.1.dist-info/WHEEL +4 -0
- simple_module_users-0.0.1.dist-info/entry_points.txt +5 -0
- simple_module_users-0.0.1.dist-info/licenses/LICENSE +21 -0
- users/__init__.py +0 -0
- users/backend.py +85 -0
- users/bootstrap.py +246 -0
- users/cli.py +75 -0
- users/components/IndexFilters.tsx +72 -0
- users/components/RolesTab.tsx +72 -0
- users/constants.py +42 -0
- users/contracts/__init__.py +0 -0
- users/contracts/events.py +32 -0
- users/contracts/schemas.py +85 -0
- users/db_adapter.py +48 -0
- users/deps.py +83 -0
- users/endpoints/__init__.py +1 -0
- users/endpoints/api.py +227 -0
- users/endpoints/api_admin.py +167 -0
- users/endpoints/views.py +220 -0
- users/exceptions.py +18 -0
- users/mailer/__init__.py +33 -0
- users/mailer/console.py +27 -0
- users/mailer/smtp.py +77 -0
- users/mailer/templates/.gitkeep +0 -0
- users/mailer/templates/invite.txt +1 -0
- users/mailer/templates/reset_password.txt +1 -0
- users/mailer/templates/verify_email.txt +1 -0
- users/manager.py +146 -0
- users/middleware.py +143 -0
- users/models/__init__.py +24 -0
- users/models/_base.py +9 -0
- users/models/access_token.py +33 -0
- users/models/role.py +34 -0
- users/models/user.py +67 -0
- users/models/user_role.py +39 -0
- users/module.py +155 -0
- users/package.json +16 -0
- users/pages/.gitkeep +0 -0
- users/pages/AcceptInvite.tsx +106 -0
- users/pages/ForgotPassword.tsx +90 -0
- users/pages/Login.tsx +181 -0
- users/pages/Profile.tsx +112 -0
- users/pages/Register.tsx +152 -0
- users/pages/ResetPassword.tsx +112 -0
- users/pages/Users/Edit.tsx +293 -0
- users/pages/Users/Index.tsx +296 -0
- users/pages/Users/Invite.tsx +135 -0
- users/pages/VerifyEmail.tsx +110 -0
- users/py.typed +0 -0
- users/rate_limit.py +59 -0
- users/roles_cache.py +58 -0
- users/service.py +257 -0
- users/settings.py +99 -0
- users/state.py +33 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { router, usePage } from '@inertiajs/react';
|
|
2
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
3
|
+
import {
|
|
4
|
+
Card,
|
|
5
|
+
CardContent,
|
|
6
|
+
CardDescription,
|
|
7
|
+
CardHeader,
|
|
8
|
+
CardTitle,
|
|
9
|
+
} from '@simple-module-py/ui/components/ui/card';
|
|
10
|
+
import { Input } from '@simple-module-py/ui/components/ui/input';
|
|
11
|
+
import { Label } from '@simple-module-py/ui/components/ui/label';
|
|
12
|
+
import { AuthCardShell } from '@simple-module-py/ui/layouts/AuthCardShell';
|
|
13
|
+
import { useState } from 'react';
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
token: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function AcceptInvite() {
|
|
20
|
+
const { token: initialToken } = usePage<{ props: Props }>().props as unknown as Props;
|
|
21
|
+
const urlToken =
|
|
22
|
+
typeof window !== 'undefined'
|
|
23
|
+
? (new URLSearchParams(window.location.search).get('token') ?? '')
|
|
24
|
+
: '';
|
|
25
|
+
const token = urlToken || initialToken;
|
|
26
|
+
|
|
27
|
+
const [password, setPassword] = useState('');
|
|
28
|
+
const [confirm, setConfirm] = useState('');
|
|
29
|
+
const [error, setError] = useState<string | null>(null);
|
|
30
|
+
const [loading, setLoading] = useState(false);
|
|
31
|
+
|
|
32
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
setError(null);
|
|
35
|
+
if (password !== confirm) {
|
|
36
|
+
setError('Passwords do not match.');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
setLoading(true);
|
|
40
|
+
fetch('/api/users/auth/accept-invite', {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'Content-Type': 'application/json' },
|
|
43
|
+
body: JSON.stringify({ token, password }),
|
|
44
|
+
})
|
|
45
|
+
.then(async (res) => {
|
|
46
|
+
if (res.status === 204 || res.status === 200) {
|
|
47
|
+
router.visit('/dashboard/');
|
|
48
|
+
} else {
|
|
49
|
+
const data = await res.json().catch(() => ({}));
|
|
50
|
+
const detail = typeof data?.detail === 'string' ? data.detail : '';
|
|
51
|
+
if (detail === 'INVITE_BAD_TOKEN') {
|
|
52
|
+
setError('Invite link has expired or is invalid. Please request a new invitation.');
|
|
53
|
+
} else {
|
|
54
|
+
setError(detail || 'Failed to accept invite. Please try again.');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
.catch(() => setError('An error occurred. Please try again.'))
|
|
59
|
+
.finally(() => setLoading(false));
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<AuthCardShell>
|
|
64
|
+
<Card className="w-full max-w-sm">
|
|
65
|
+
<CardHeader className="space-y-1">
|
|
66
|
+
<CardTitle className="text-2xl">Accept invitation</CardTitle>
|
|
67
|
+
<CardDescription>Set a password to activate your account</CardDescription>
|
|
68
|
+
</CardHeader>
|
|
69
|
+
<CardContent>
|
|
70
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
71
|
+
<div className="space-y-2">
|
|
72
|
+
<Label htmlFor="password">Password</Label>
|
|
73
|
+
<Input
|
|
74
|
+
id="password"
|
|
75
|
+
type="password"
|
|
76
|
+
value={password}
|
|
77
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
78
|
+
required
|
|
79
|
+
autoComplete="new-password"
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
<div className="space-y-2">
|
|
83
|
+
<Label htmlFor="confirm">Confirm password</Label>
|
|
84
|
+
<Input
|
|
85
|
+
id="confirm"
|
|
86
|
+
type="password"
|
|
87
|
+
value={confirm}
|
|
88
|
+
onChange={(e) => setConfirm(e.target.value)}
|
|
89
|
+
required
|
|
90
|
+
autoComplete="new-password"
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
95
|
+
|
|
96
|
+
<Button type="submit" className="w-full" disabled={loading || !token}>
|
|
97
|
+
{loading ? 'Activating…' : 'Activate account'}
|
|
98
|
+
</Button>
|
|
99
|
+
</form>
|
|
100
|
+
</CardContent>
|
|
101
|
+
</Card>
|
|
102
|
+
</AuthCardShell>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export default AcceptInvite;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
2
|
+
import {
|
|
3
|
+
Card,
|
|
4
|
+
CardContent,
|
|
5
|
+
CardDescription,
|
|
6
|
+
CardHeader,
|
|
7
|
+
CardTitle,
|
|
8
|
+
} from '@simple-module-py/ui/components/ui/card';
|
|
9
|
+
import { Input } from '@simple-module-py/ui/components/ui/input';
|
|
10
|
+
import { Label } from '@simple-module-py/ui/components/ui/label';
|
|
11
|
+
import { AuthCardShell } from '@simple-module-py/ui/layouts/AuthCardShell';
|
|
12
|
+
import { useState } from 'react';
|
|
13
|
+
|
|
14
|
+
function ForgotPassword() {
|
|
15
|
+
const [email, setEmail] = useState('');
|
|
16
|
+
const [submitted, setSubmitted] = useState(false);
|
|
17
|
+
const [loading, setLoading] = useState(false);
|
|
18
|
+
|
|
19
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
20
|
+
e.preventDefault();
|
|
21
|
+
setLoading(true);
|
|
22
|
+
fetch('/api/users/auth/forgot-password', {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: { 'Content-Type': 'application/json' },
|
|
25
|
+
body: JSON.stringify({ email }),
|
|
26
|
+
}).finally(() => {
|
|
27
|
+
// Always show the same message regardless of whether the email exists
|
|
28
|
+
// (anti-enumeration: fastapi-users returns 202 regardless)
|
|
29
|
+
setLoading(false);
|
|
30
|
+
setSubmitted(true);
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
if (submitted) {
|
|
35
|
+
return (
|
|
36
|
+
<AuthCardShell>
|
|
37
|
+
<Card className="w-full max-w-sm">
|
|
38
|
+
<CardHeader>
|
|
39
|
+
<CardTitle>Check your email</CardTitle>
|
|
40
|
+
<CardDescription>
|
|
41
|
+
If an account with that email exists, we've sent a password reset link.
|
|
42
|
+
</CardDescription>
|
|
43
|
+
</CardHeader>
|
|
44
|
+
<CardContent>
|
|
45
|
+
<a href="/users/login" className="text-sm text-primary hover:underline">
|
|
46
|
+
Back to sign in
|
|
47
|
+
</a>
|
|
48
|
+
</CardContent>
|
|
49
|
+
</Card>
|
|
50
|
+
</AuthCardShell>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<AuthCardShell>
|
|
56
|
+
<Card className="w-full max-w-sm">
|
|
57
|
+
<CardHeader className="space-y-1">
|
|
58
|
+
<CardTitle className="text-2xl">Forgot password</CardTitle>
|
|
59
|
+
<CardDescription>Enter your email to receive a reset link</CardDescription>
|
|
60
|
+
</CardHeader>
|
|
61
|
+
<CardContent>
|
|
62
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
63
|
+
<div className="space-y-2">
|
|
64
|
+
<Label htmlFor="email">Email</Label>
|
|
65
|
+
<Input
|
|
66
|
+
id="email"
|
|
67
|
+
type="email"
|
|
68
|
+
value={email}
|
|
69
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
70
|
+
placeholder="you@example.com"
|
|
71
|
+
required
|
|
72
|
+
autoComplete="email"
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
<Button type="submit" className="w-full" disabled={loading}>
|
|
76
|
+
{loading ? 'Sending…' : 'Send reset link'}
|
|
77
|
+
</Button>
|
|
78
|
+
</form>
|
|
79
|
+
<p className="mt-4 text-center text-sm text-muted-foreground">
|
|
80
|
+
<a href="/users/login" className="text-primary hover:underline">
|
|
81
|
+
Back to sign in
|
|
82
|
+
</a>
|
|
83
|
+
</p>
|
|
84
|
+
</CardContent>
|
|
85
|
+
</Card>
|
|
86
|
+
</AuthCardShell>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export default ForgotPassword;
|
users/pages/Login.tsx
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { router, usePage } from '@inertiajs/react';
|
|
2
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
3
|
+
import {
|
|
4
|
+
Card,
|
|
5
|
+
CardContent,
|
|
6
|
+
CardDescription,
|
|
7
|
+
CardHeader,
|
|
8
|
+
CardTitle,
|
|
9
|
+
} from '@simple-module-py/ui/components/ui/card';
|
|
10
|
+
import { Input } from '@simple-module-py/ui/components/ui/input';
|
|
11
|
+
import { Label } from '@simple-module-py/ui/components/ui/label';
|
|
12
|
+
import { AuthCardShell } from '@simple-module-py/ui/layouts/AuthCardShell';
|
|
13
|
+
import { useState } from 'react';
|
|
14
|
+
|
|
15
|
+
interface DevAccount {
|
|
16
|
+
label: string;
|
|
17
|
+
email: string;
|
|
18
|
+
password: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
allow_signup: boolean;
|
|
23
|
+
dev_accounts: DevAccount[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function Login() {
|
|
27
|
+
const { allow_signup, dev_accounts } = usePage<{ props: Props }>().props as unknown as Props;
|
|
28
|
+
|
|
29
|
+
const [email, setEmail] = useState('');
|
|
30
|
+
const [password, setPassword] = useState('');
|
|
31
|
+
const [error, setError] = useState<string | null>(null);
|
|
32
|
+
const [needsVerification, setNeedsVerification] = useState(false);
|
|
33
|
+
const [loading, setLoading] = useState(false);
|
|
34
|
+
|
|
35
|
+
const nextUrl =
|
|
36
|
+
typeof window !== 'undefined'
|
|
37
|
+
? new URLSearchParams(window.location.search).get('next') || '/dashboard/'
|
|
38
|
+
: '/dashboard/';
|
|
39
|
+
|
|
40
|
+
const submitLogin = (username: string, pwd: string) => {
|
|
41
|
+
setError(null);
|
|
42
|
+
setNeedsVerification(false);
|
|
43
|
+
setLoading(true);
|
|
44
|
+
const body = new URLSearchParams({ username, password: pwd });
|
|
45
|
+
fetch('/api/users/auth/login', {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
body,
|
|
48
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
49
|
+
})
|
|
50
|
+
.then(async (res) => {
|
|
51
|
+
if (res.status === 204) {
|
|
52
|
+
router.visit(nextUrl);
|
|
53
|
+
} else if (res.status === 429) {
|
|
54
|
+
setError('Too many attempts. Please try again in a few minutes.');
|
|
55
|
+
} else {
|
|
56
|
+
const data = await res.json().catch(() => ({}));
|
|
57
|
+
const detail = typeof data?.detail === 'string' ? data.detail : '';
|
|
58
|
+
if (detail === 'LOGIN_USER_NOT_VERIFIED') {
|
|
59
|
+
setNeedsVerification(true);
|
|
60
|
+
} else {
|
|
61
|
+
setError('Invalid email or password.');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
.catch(() => setError('An error occurred. Please try again.'))
|
|
66
|
+
.finally(() => setLoading(false));
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
submitLogin(email, password);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const handleResendVerification = () => {
|
|
75
|
+
fetch('/api/users/auth/request-verify-token', {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'Content-Type': 'application/json' },
|
|
78
|
+
body: JSON.stringify({ email }),
|
|
79
|
+
}).then(() => {
|
|
80
|
+
setError('Verification email resent. Please check your inbox.');
|
|
81
|
+
setNeedsVerification(false);
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<AuthCardShell>
|
|
87
|
+
<Card className="w-full max-w-sm">
|
|
88
|
+
<CardHeader className="space-y-1">
|
|
89
|
+
<CardTitle className="text-2xl">Sign in</CardTitle>
|
|
90
|
+
<CardDescription>Enter your email and password to continue</CardDescription>
|
|
91
|
+
</CardHeader>
|
|
92
|
+
<CardContent>
|
|
93
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
94
|
+
<div className="space-y-2">
|
|
95
|
+
<Label htmlFor="email">Email</Label>
|
|
96
|
+
<Input
|
|
97
|
+
id="email"
|
|
98
|
+
type="email"
|
|
99
|
+
value={email}
|
|
100
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
101
|
+
placeholder="you@example.com"
|
|
102
|
+
required
|
|
103
|
+
autoComplete="email"
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
<div className="space-y-2">
|
|
107
|
+
<div className="flex items-center justify-between">
|
|
108
|
+
<Label htmlFor="password">Password</Label>
|
|
109
|
+
<a href="/users/forgot-password" className="text-sm text-primary hover:underline">
|
|
110
|
+
Forgot password?
|
|
111
|
+
</a>
|
|
112
|
+
</div>
|
|
113
|
+
<Input
|
|
114
|
+
id="password"
|
|
115
|
+
type="password"
|
|
116
|
+
value={password}
|
|
117
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
118
|
+
required
|
|
119
|
+
autoComplete="current-password"
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
124
|
+
|
|
125
|
+
{needsVerification && (
|
|
126
|
+
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
|
127
|
+
<p>Please verify your email before signing in.</p>
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
onClick={handleResendVerification}
|
|
131
|
+
className="mt-1 underline hover:no-underline"
|
|
132
|
+
>
|
|
133
|
+
Resend verification email
|
|
134
|
+
</button>
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
<Button type="submit" className="w-full" disabled={loading}>
|
|
139
|
+
{loading ? 'Signing in…' : 'Sign in'}
|
|
140
|
+
</Button>
|
|
141
|
+
</form>
|
|
142
|
+
|
|
143
|
+
{allow_signup && (
|
|
144
|
+
<p className="mt-4 text-center text-sm text-muted-foreground">
|
|
145
|
+
Don't have an account?{' '}
|
|
146
|
+
<a href="/users/register" className="text-primary hover:underline">
|
|
147
|
+
Create account
|
|
148
|
+
</a>
|
|
149
|
+
</p>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
{dev_accounts && dev_accounts.length > 0 && (
|
|
153
|
+
<div className="mt-6 border-t pt-4">
|
|
154
|
+
<p className="mb-2 text-center text-xs text-muted-foreground">Dev quick-login</p>
|
|
155
|
+
<div className="flex flex-wrap justify-center gap-2">
|
|
156
|
+
{dev_accounts.map((acct) => (
|
|
157
|
+
<Button
|
|
158
|
+
key={acct.email}
|
|
159
|
+
type="button"
|
|
160
|
+
variant="outline"
|
|
161
|
+
size="sm"
|
|
162
|
+
disabled={loading}
|
|
163
|
+
onClick={() => {
|
|
164
|
+
setEmail(acct.email);
|
|
165
|
+
setPassword(acct.password);
|
|
166
|
+
submitLogin(acct.email, acct.password);
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
{acct.label}
|
|
170
|
+
</Button>
|
|
171
|
+
))}
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
</CardContent>
|
|
176
|
+
</Card>
|
|
177
|
+
</AuthCardShell>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export default Login;
|
users/pages/Profile.tsx
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { usePage } from '@inertiajs/react';
|
|
2
|
+
import { PageShell } from '@simple-module-py/ui/components/PageShell';
|
|
3
|
+
import { Badge } from '@simple-module-py/ui/components/ui/badge';
|
|
4
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
5
|
+
import { Card, CardContent } from '@simple-module-py/ui/components/ui/card';
|
|
6
|
+
import { Input } from '@simple-module-py/ui/components/ui/input';
|
|
7
|
+
import { Label } from '@simple-module-py/ui/components/ui/label';
|
|
8
|
+
import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
|
|
9
|
+
import { useState } from 'react';
|
|
10
|
+
import { toast } from 'sonner';
|
|
11
|
+
|
|
12
|
+
interface AuthUser {
|
|
13
|
+
id: string;
|
|
14
|
+
email: string;
|
|
15
|
+
full_name: string | null;
|
|
16
|
+
is_verified: boolean;
|
|
17
|
+
roles: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface SharedProps {
|
|
21
|
+
auth: {
|
|
22
|
+
user: AuthUser | null;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function Profile() {
|
|
27
|
+
const { auth } = usePage<{ props: SharedProps }>().props as unknown as SharedProps;
|
|
28
|
+
const user = auth?.user;
|
|
29
|
+
|
|
30
|
+
const [fullName, setFullName] = useState(user?.full_name ?? '');
|
|
31
|
+
const [saving, setSaving] = useState(false);
|
|
32
|
+
|
|
33
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
34
|
+
e.preventDefault();
|
|
35
|
+
setSaving(true);
|
|
36
|
+
fetch('/api/users/me', {
|
|
37
|
+
method: 'PATCH',
|
|
38
|
+
headers: { 'Content-Type': 'application/json' },
|
|
39
|
+
body: JSON.stringify({ full_name: fullName }),
|
|
40
|
+
})
|
|
41
|
+
.then(async (res) => {
|
|
42
|
+
if (res.ok) {
|
|
43
|
+
toast.success('Profile updated');
|
|
44
|
+
} else {
|
|
45
|
+
const data = await res.json().catch(() => ({}));
|
|
46
|
+
toast.error(typeof data?.detail === 'string' ? data.detail : 'Failed to update profile');
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
.catch(() => toast.error('An error occurred'))
|
|
50
|
+
.finally(() => setSaving(false));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
if (!user) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<PageShell title="My Profile" description="Manage your account details">
|
|
59
|
+
<Card className="max-w-xl">
|
|
60
|
+
<CardContent className="pt-6">
|
|
61
|
+
<form onSubmit={handleSubmit} className="space-y-5">
|
|
62
|
+
<div className="space-y-2">
|
|
63
|
+
<Label htmlFor="email">Email</Label>
|
|
64
|
+
<Input id="email" type="email" value={user.email} readOnly className="bg-muted" />
|
|
65
|
+
<div className="flex items-center gap-2">
|
|
66
|
+
{user.is_verified ? (
|
|
67
|
+
<Badge variant="secondary">Verified</Badge>
|
|
68
|
+
) : (
|
|
69
|
+
<Badge variant="destructive">Unverified</Badge>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<div className="space-y-2">
|
|
75
|
+
<Label htmlFor="full_name">Full name</Label>
|
|
76
|
+
<Input
|
|
77
|
+
id="full_name"
|
|
78
|
+
type="text"
|
|
79
|
+
value={fullName}
|
|
80
|
+
onChange={(e) => setFullName(e.target.value)}
|
|
81
|
+
placeholder="Your name"
|
|
82
|
+
maxLength={200}
|
|
83
|
+
/>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
{user.roles.length > 0 && (
|
|
87
|
+
<div className="space-y-2">
|
|
88
|
+
<Label>Roles</Label>
|
|
89
|
+
<div className="flex flex-wrap gap-2">
|
|
90
|
+
{user.roles.map((role) => (
|
|
91
|
+
<Badge key={role} variant="outline">
|
|
92
|
+
{role}
|
|
93
|
+
</Badge>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
|
|
99
|
+
<div className="pt-2">
|
|
100
|
+
<Button type="submit" disabled={saving}>
|
|
101
|
+
{saving ? 'Saving…' : 'Save changes'}
|
|
102
|
+
</Button>
|
|
103
|
+
</div>
|
|
104
|
+
</form>
|
|
105
|
+
</CardContent>
|
|
106
|
+
</Card>
|
|
107
|
+
</PageShell>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
Profile.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
|
|
112
|
+
export default Profile;
|
users/pages/Register.tsx
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
2
|
+
import {
|
|
3
|
+
Card,
|
|
4
|
+
CardContent,
|
|
5
|
+
CardDescription,
|
|
6
|
+
CardHeader,
|
|
7
|
+
CardTitle,
|
|
8
|
+
} from '@simple-module-py/ui/components/ui/card';
|
|
9
|
+
import { Input } from '@simple-module-py/ui/components/ui/input';
|
|
10
|
+
import { Label } from '@simple-module-py/ui/components/ui/label';
|
|
11
|
+
import { AuthCardShell } from '@simple-module-py/ui/layouts/AuthCardShell';
|
|
12
|
+
import { useState } from 'react';
|
|
13
|
+
|
|
14
|
+
function Register() {
|
|
15
|
+
const [email, setEmail] = useState('');
|
|
16
|
+
const [fullName, setFullName] = useState('');
|
|
17
|
+
const [password, setPassword] = useState('');
|
|
18
|
+
const [confirm, setConfirm] = useState('');
|
|
19
|
+
const [error, setError] = useState<string | null>(null);
|
|
20
|
+
const [success, setSuccess] = useState(false);
|
|
21
|
+
const [loading, setLoading] = useState(false);
|
|
22
|
+
|
|
23
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
24
|
+
e.preventDefault();
|
|
25
|
+
setError(null);
|
|
26
|
+
if (password !== confirm) {
|
|
27
|
+
setError('Passwords do not match.');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
setLoading(true);
|
|
31
|
+
fetch('/api/users/auth/register', {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: { 'Content-Type': 'application/json' },
|
|
34
|
+
body: JSON.stringify({ email, password, full_name: fullName }),
|
|
35
|
+
})
|
|
36
|
+
.then(async (res) => {
|
|
37
|
+
if (res.status === 201) {
|
|
38
|
+
setSuccess(true);
|
|
39
|
+
} else {
|
|
40
|
+
const data = await res.json().catch(() => ({}));
|
|
41
|
+
const detail = data?.detail;
|
|
42
|
+
if (detail === 'REGISTER_USER_ALREADY_EXISTS') {
|
|
43
|
+
setError('An account with this email already exists.');
|
|
44
|
+
} else if (typeof detail === 'object' && detail?.code === 'REGISTER_INVALID_PASSWORD') {
|
|
45
|
+
setError(`Password not accepted: ${detail.reason ?? 'too weak'}`);
|
|
46
|
+
} else if (typeof detail === 'string') {
|
|
47
|
+
setError(detail);
|
|
48
|
+
} else {
|
|
49
|
+
setError('Registration failed. Please try again.');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
.catch(() => setError('An error occurred. Please try again.'))
|
|
54
|
+
.finally(() => setLoading(false));
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
if (success) {
|
|
58
|
+
return (
|
|
59
|
+
<AuthCardShell>
|
|
60
|
+
<Card className="w-full max-w-sm">
|
|
61
|
+
<CardHeader>
|
|
62
|
+
<CardTitle>Check your email</CardTitle>
|
|
63
|
+
<CardDescription>
|
|
64
|
+
We've sent a verification link to <strong>{email}</strong>. Please verify your account
|
|
65
|
+
before signing in.
|
|
66
|
+
</CardDescription>
|
|
67
|
+
</CardHeader>
|
|
68
|
+
<CardContent>
|
|
69
|
+
<a href="/users/login" className="text-sm text-primary hover:underline">
|
|
70
|
+
Back to sign in
|
|
71
|
+
</a>
|
|
72
|
+
</CardContent>
|
|
73
|
+
</Card>
|
|
74
|
+
</AuthCardShell>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<AuthCardShell>
|
|
80
|
+
<Card className="w-full max-w-sm">
|
|
81
|
+
<CardHeader className="space-y-1">
|
|
82
|
+
<CardTitle className="text-2xl">Create account</CardTitle>
|
|
83
|
+
<CardDescription>Fill in your details to get started</CardDescription>
|
|
84
|
+
</CardHeader>
|
|
85
|
+
<CardContent>
|
|
86
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
87
|
+
<div className="space-y-2">
|
|
88
|
+
<Label htmlFor="email">Email</Label>
|
|
89
|
+
<Input
|
|
90
|
+
id="email"
|
|
91
|
+
type="email"
|
|
92
|
+
value={email}
|
|
93
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
94
|
+
placeholder="you@example.com"
|
|
95
|
+
required
|
|
96
|
+
autoComplete="email"
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
<div className="space-y-2">
|
|
100
|
+
<Label htmlFor="full_name">Full name</Label>
|
|
101
|
+
<Input
|
|
102
|
+
id="full_name"
|
|
103
|
+
type="text"
|
|
104
|
+
value={fullName}
|
|
105
|
+
onChange={(e) => setFullName(e.target.value)}
|
|
106
|
+
placeholder="Your name"
|
|
107
|
+
autoComplete="name"
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
<div className="space-y-2">
|
|
111
|
+
<Label htmlFor="password">Password</Label>
|
|
112
|
+
<Input
|
|
113
|
+
id="password"
|
|
114
|
+
type="password"
|
|
115
|
+
value={password}
|
|
116
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
117
|
+
required
|
|
118
|
+
autoComplete="new-password"
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
<div className="space-y-2">
|
|
122
|
+
<Label htmlFor="confirm">Confirm password</Label>
|
|
123
|
+
<Input
|
|
124
|
+
id="confirm"
|
|
125
|
+
type="password"
|
|
126
|
+
value={confirm}
|
|
127
|
+
onChange={(e) => setConfirm(e.target.value)}
|
|
128
|
+
required
|
|
129
|
+
autoComplete="new-password"
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
134
|
+
|
|
135
|
+
<Button type="submit" className="w-full" disabled={loading}>
|
|
136
|
+
{loading ? 'Creating account…' : 'Create account'}
|
|
137
|
+
</Button>
|
|
138
|
+
</form>
|
|
139
|
+
|
|
140
|
+
<p className="mt-4 text-center text-sm text-muted-foreground">
|
|
141
|
+
Already have an account?{' '}
|
|
142
|
+
<a href="/users/login" className="text-primary hover:underline">
|
|
143
|
+
Sign in
|
|
144
|
+
</a>
|
|
145
|
+
</p>
|
|
146
|
+
</CardContent>
|
|
147
|
+
</Card>
|
|
148
|
+
</AuthCardShell>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export default Register;
|