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.
Files changed (56) hide show
  1. simple_module_users-0.0.1.dist-info/METADATA +88 -0
  2. simple_module_users-0.0.1.dist-info/RECORD +56 -0
  3. simple_module_users-0.0.1.dist-info/WHEEL +4 -0
  4. simple_module_users-0.0.1.dist-info/entry_points.txt +5 -0
  5. simple_module_users-0.0.1.dist-info/licenses/LICENSE +21 -0
  6. users/__init__.py +0 -0
  7. users/backend.py +85 -0
  8. users/bootstrap.py +246 -0
  9. users/cli.py +75 -0
  10. users/components/IndexFilters.tsx +72 -0
  11. users/components/RolesTab.tsx +72 -0
  12. users/constants.py +42 -0
  13. users/contracts/__init__.py +0 -0
  14. users/contracts/events.py +32 -0
  15. users/contracts/schemas.py +85 -0
  16. users/db_adapter.py +48 -0
  17. users/deps.py +83 -0
  18. users/endpoints/__init__.py +1 -0
  19. users/endpoints/api.py +227 -0
  20. users/endpoints/api_admin.py +167 -0
  21. users/endpoints/views.py +220 -0
  22. users/exceptions.py +18 -0
  23. users/mailer/__init__.py +33 -0
  24. users/mailer/console.py +27 -0
  25. users/mailer/smtp.py +77 -0
  26. users/mailer/templates/.gitkeep +0 -0
  27. users/mailer/templates/invite.txt +1 -0
  28. users/mailer/templates/reset_password.txt +1 -0
  29. users/mailer/templates/verify_email.txt +1 -0
  30. users/manager.py +146 -0
  31. users/middleware.py +143 -0
  32. users/models/__init__.py +24 -0
  33. users/models/_base.py +9 -0
  34. users/models/access_token.py +33 -0
  35. users/models/role.py +34 -0
  36. users/models/user.py +67 -0
  37. users/models/user_role.py +39 -0
  38. users/module.py +155 -0
  39. users/package.json +16 -0
  40. users/pages/.gitkeep +0 -0
  41. users/pages/AcceptInvite.tsx +106 -0
  42. users/pages/ForgotPassword.tsx +90 -0
  43. users/pages/Login.tsx +181 -0
  44. users/pages/Profile.tsx +112 -0
  45. users/pages/Register.tsx +152 -0
  46. users/pages/ResetPassword.tsx +112 -0
  47. users/pages/Users/Edit.tsx +293 -0
  48. users/pages/Users/Index.tsx +296 -0
  49. users/pages/Users/Invite.tsx +135 -0
  50. users/pages/VerifyEmail.tsx +110 -0
  51. users/py.typed +0 -0
  52. users/rate_limit.py +59 -0
  53. users/roles_cache.py +58 -0
  54. users/service.py +257 -0
  55. users/settings.py +99 -0
  56. 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;
@@ -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;
@@ -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;