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,112 @@
|
|
|
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 ResetPassword() {
|
|
20
|
+
const { token: initialToken } = usePage<{ props: Props }>().props as unknown as Props;
|
|
21
|
+
|
|
22
|
+
// Prefer the token from the URL query string (deeplink); fall back to Inertia prop.
|
|
23
|
+
const urlToken =
|
|
24
|
+
typeof window !== 'undefined'
|
|
25
|
+
? (new URLSearchParams(window.location.search).get('token') ?? '')
|
|
26
|
+
: '';
|
|
27
|
+
const token = urlToken || initialToken;
|
|
28
|
+
|
|
29
|
+
const [password, setPassword] = useState('');
|
|
30
|
+
const [confirm, setConfirm] = useState('');
|
|
31
|
+
const [error, setError] = useState<string | null>(null);
|
|
32
|
+
const [loading, setLoading] = useState(false);
|
|
33
|
+
|
|
34
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
setError(null);
|
|
37
|
+
if (password !== confirm) {
|
|
38
|
+
setError('Passwords do not match.');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
setLoading(true);
|
|
42
|
+
fetch('/api/users/auth/reset-password', {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'Content-Type': 'application/json' },
|
|
45
|
+
body: JSON.stringify({ token, password }),
|
|
46
|
+
})
|
|
47
|
+
.then(async (res) => {
|
|
48
|
+
if (res.status === 200 || res.status === 204) {
|
|
49
|
+
router.visit('/users/login');
|
|
50
|
+
} else {
|
|
51
|
+
const data = await res.json().catch(() => ({}));
|
|
52
|
+
const detail =
|
|
53
|
+
typeof data?.detail === 'string'
|
|
54
|
+
? data.detail
|
|
55
|
+
: 'Reset failed. The link may have expired.';
|
|
56
|
+
setError(detail);
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
.catch(() => setError('An error occurred. Please try again.'))
|
|
60
|
+
.finally(() => setLoading(false));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<AuthCardShell>
|
|
65
|
+
<Card className="w-full max-w-sm">
|
|
66
|
+
<CardHeader className="space-y-1">
|
|
67
|
+
<CardTitle className="text-2xl">Reset password</CardTitle>
|
|
68
|
+
<CardDescription>Choose a new password for your account</CardDescription>
|
|
69
|
+
</CardHeader>
|
|
70
|
+
<CardContent>
|
|
71
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
72
|
+
<div className="space-y-2">
|
|
73
|
+
<Label htmlFor="password">New password</Label>
|
|
74
|
+
<Input
|
|
75
|
+
id="password"
|
|
76
|
+
type="password"
|
|
77
|
+
value={password}
|
|
78
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
79
|
+
required
|
|
80
|
+
autoComplete="new-password"
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
<div className="space-y-2">
|
|
84
|
+
<Label htmlFor="confirm">Confirm password</Label>
|
|
85
|
+
<Input
|
|
86
|
+
id="confirm"
|
|
87
|
+
type="password"
|
|
88
|
+
value={confirm}
|
|
89
|
+
onChange={(e) => setConfirm(e.target.value)}
|
|
90
|
+
required
|
|
91
|
+
autoComplete="new-password"
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
96
|
+
|
|
97
|
+
<Button type="submit" className="w-full" disabled={loading || !token}>
|
|
98
|
+
{loading ? 'Resetting…' : 'Reset password'}
|
|
99
|
+
</Button>
|
|
100
|
+
</form>
|
|
101
|
+
{!token && (
|
|
102
|
+
<p className="mt-2 text-sm text-destructive">
|
|
103
|
+
No reset token found. Please use the link from your email.
|
|
104
|
+
</p>
|
|
105
|
+
)}
|
|
106
|
+
</CardContent>
|
|
107
|
+
</Card>
|
|
108
|
+
</AuthCardShell>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export default ResetPassword;
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { Link, router, usePage } from '@inertiajs/react';
|
|
2
|
+
import { PageShell } from '@simple-module-py/ui/components/PageShell';
|
|
3
|
+
import {
|
|
4
|
+
AlertDialog,
|
|
5
|
+
AlertDialogAction,
|
|
6
|
+
AlertDialogCancel,
|
|
7
|
+
AlertDialogContent,
|
|
8
|
+
AlertDialogDescription,
|
|
9
|
+
AlertDialogFooter,
|
|
10
|
+
AlertDialogHeader,
|
|
11
|
+
AlertDialogTitle,
|
|
12
|
+
AlertDialogTrigger,
|
|
13
|
+
} from '@simple-module-py/ui/components/ui/alert-dialog';
|
|
14
|
+
import { Badge } from '@simple-module-py/ui/components/ui/badge';
|
|
15
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
16
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@simple-module-py/ui/components/ui/card';
|
|
17
|
+
import { Checkbox } from '@simple-module-py/ui/components/ui/checkbox';
|
|
18
|
+
import { Label } from '@simple-module-py/ui/components/ui/label';
|
|
19
|
+
import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
|
|
20
|
+
import { ShieldCheck } from 'lucide-react';
|
|
21
|
+
import { useState } from 'react';
|
|
22
|
+
import { toast } from 'sonner';
|
|
23
|
+
|
|
24
|
+
interface UserListItem {
|
|
25
|
+
id: string;
|
|
26
|
+
email: string;
|
|
27
|
+
full_name: string | null;
|
|
28
|
+
is_active: boolean;
|
|
29
|
+
is_verified: boolean;
|
|
30
|
+
disabled_at: string | null;
|
|
31
|
+
last_login_at: string | null;
|
|
32
|
+
created_at: string | null;
|
|
33
|
+
roles: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface Role {
|
|
37
|
+
id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface Props {
|
|
42
|
+
user: UserListItem;
|
|
43
|
+
roles: Role[];
|
|
44
|
+
has_permissions_module: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function fmt(dt: string | null): string {
|
|
48
|
+
if (!dt) return '—';
|
|
49
|
+
return new Date(dt).toLocaleString();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function Edit() {
|
|
53
|
+
const { user, roles, has_permissions_module } = usePage<{ props: Props }>()
|
|
54
|
+
.props as unknown as Props;
|
|
55
|
+
|
|
56
|
+
const [isActive, setIsActive] = useState(user.is_active);
|
|
57
|
+
const [isVerified, setIsVerified] = useState(user.is_verified);
|
|
58
|
+
const [selectedRoles, setSelectedRoles] = useState<string[]>(user.roles ?? []);
|
|
59
|
+
const [savingStatus, setSavingStatus] = useState(false);
|
|
60
|
+
const [savingRoles, setSavingRoles] = useState(false);
|
|
61
|
+
const [savingVerify, setSavingVerify] = useState(false);
|
|
62
|
+
|
|
63
|
+
const toggleRole = (roleName: string) => {
|
|
64
|
+
setSelectedRoles((prev) =>
|
|
65
|
+
prev.includes(roleName) ? prev.filter((r) => r !== roleName) : [...prev, roleName],
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const disableAccount = () => {
|
|
70
|
+
setSavingStatus(true);
|
|
71
|
+
fetch(`/api/users/admin/${user.id}/disable`, { method: 'PATCH' })
|
|
72
|
+
.then(async (res) => {
|
|
73
|
+
if (res.ok) {
|
|
74
|
+
setIsActive(false);
|
|
75
|
+
toast.success('User disabled');
|
|
76
|
+
} else {
|
|
77
|
+
toast.error('Failed to disable user');
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
.catch(() => toast.error('An error occurred'))
|
|
81
|
+
.finally(() => setSavingStatus(false));
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const enableAccount = () => {
|
|
85
|
+
setSavingStatus(true);
|
|
86
|
+
fetch(`/api/users/admin/${user.id}/enable`, { method: 'PATCH' })
|
|
87
|
+
.then(async (res) => {
|
|
88
|
+
if (res.ok) {
|
|
89
|
+
setIsActive(true);
|
|
90
|
+
toast.success('User enabled');
|
|
91
|
+
} else {
|
|
92
|
+
toast.error('Failed to enable user');
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
.catch(() => toast.error('An error occurred'))
|
|
96
|
+
.finally(() => setSavingStatus(false));
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const handleSaveRoles = () => {
|
|
100
|
+
setSavingRoles(true);
|
|
101
|
+
fetch(`/api/users/admin/${user.id}/roles`, {
|
|
102
|
+
method: 'PUT',
|
|
103
|
+
headers: { 'Content-Type': 'application/json' },
|
|
104
|
+
body: JSON.stringify({ role_names: selectedRoles }),
|
|
105
|
+
})
|
|
106
|
+
.then(async (res) => {
|
|
107
|
+
if (res.ok) {
|
|
108
|
+
toast.success('Roles updated');
|
|
109
|
+
} else {
|
|
110
|
+
toast.error('Failed to update roles');
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
.catch(() => toast.error('An error occurred'))
|
|
114
|
+
.finally(() => setSavingRoles(false));
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const copyResetLink = () => {
|
|
118
|
+
fetch(`/api/users/admin/${user.id}/reset-password-link`, { method: 'POST' })
|
|
119
|
+
.then(async (res) => {
|
|
120
|
+
if (res.ok) {
|
|
121
|
+
const data = await res.json();
|
|
122
|
+
await navigator.clipboard.writeText(data.link ?? data.url ?? '');
|
|
123
|
+
toast.success('Reset link copied to clipboard');
|
|
124
|
+
} else {
|
|
125
|
+
toast.error('Failed to generate reset link');
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
.catch(() => toast.error('An error occurred'));
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const markVerified = () => {
|
|
132
|
+
setSavingVerify(true);
|
|
133
|
+
fetch(`/api/users/admin/${user.id}/verify`, { method: 'PATCH' })
|
|
134
|
+
.then(async (res) => {
|
|
135
|
+
if (res.ok) {
|
|
136
|
+
setIsVerified(true);
|
|
137
|
+
toast.success('User marked verified');
|
|
138
|
+
} else {
|
|
139
|
+
toast.error('Failed to mark verified');
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
.catch(() => toast.error('An error occurred'))
|
|
143
|
+
.finally(() => setSavingVerify(false));
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<PageShell
|
|
148
|
+
title={user.email}
|
|
149
|
+
description={user.full_name ?? 'Edit user account'}
|
|
150
|
+
actions={
|
|
151
|
+
<Button asChild variant="outline">
|
|
152
|
+
<Link href="/users/admin">Back to Users</Link>
|
|
153
|
+
</Button>
|
|
154
|
+
}
|
|
155
|
+
>
|
|
156
|
+
<div className="space-y-6 max-w-xl">
|
|
157
|
+
<Card>
|
|
158
|
+
<CardHeader>
|
|
159
|
+
<CardTitle className="text-base">Metadata</CardTitle>
|
|
160
|
+
</CardHeader>
|
|
161
|
+
<CardContent className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-2 text-sm">
|
|
162
|
+
<span className="text-muted-foreground">Created</span>
|
|
163
|
+
<span>{fmt(user.created_at)}</span>
|
|
164
|
+
<span className="text-muted-foreground">Last login</span>
|
|
165
|
+
<span>{user.last_login_at ? fmt(user.last_login_at) : 'Never'}</span>
|
|
166
|
+
<span className="text-muted-foreground">Disabled at</span>
|
|
167
|
+
<span>{fmt(user.disabled_at)}</span>
|
|
168
|
+
<span className="text-muted-foreground">Verified</span>
|
|
169
|
+
<span className="flex items-center gap-2">
|
|
170
|
+
{isVerified ? (
|
|
171
|
+
'Yes'
|
|
172
|
+
) : (
|
|
173
|
+
<>
|
|
174
|
+
No
|
|
175
|
+
<Button
|
|
176
|
+
size="sm"
|
|
177
|
+
variant="outline"
|
|
178
|
+
onClick={markVerified}
|
|
179
|
+
disabled={savingVerify}
|
|
180
|
+
>
|
|
181
|
+
{savingVerify ? 'Saving…' : 'Mark verified'}
|
|
182
|
+
</Button>
|
|
183
|
+
</>
|
|
184
|
+
)}
|
|
185
|
+
</span>
|
|
186
|
+
</CardContent>
|
|
187
|
+
</Card>
|
|
188
|
+
|
|
189
|
+
<Card>
|
|
190
|
+
<CardHeader>
|
|
191
|
+
<CardTitle className="text-base">Account status</CardTitle>
|
|
192
|
+
</CardHeader>
|
|
193
|
+
<CardContent className="space-y-4">
|
|
194
|
+
<div className="flex items-center gap-3">
|
|
195
|
+
<Badge variant={isActive ? 'secondary' : 'destructive'}>
|
|
196
|
+
{isActive ? 'Active' : 'Disabled'}
|
|
197
|
+
</Badge>
|
|
198
|
+
</div>
|
|
199
|
+
<div className="flex flex-wrap gap-2">
|
|
200
|
+
{isActive ? (
|
|
201
|
+
<AlertDialog>
|
|
202
|
+
<AlertDialogTrigger asChild>
|
|
203
|
+
<Button variant="destructive" size="sm" disabled={savingStatus}>
|
|
204
|
+
{savingStatus ? 'Saving…' : 'Disable account'}
|
|
205
|
+
</Button>
|
|
206
|
+
</AlertDialogTrigger>
|
|
207
|
+
<AlertDialogContent>
|
|
208
|
+
<AlertDialogHeader>
|
|
209
|
+
<AlertDialogTitle>Disable {user.email}?</AlertDialogTitle>
|
|
210
|
+
<AlertDialogDescription>
|
|
211
|
+
They won't be able to sign in until you re-enable the account.
|
|
212
|
+
</AlertDialogDescription>
|
|
213
|
+
</AlertDialogHeader>
|
|
214
|
+
<AlertDialogFooter>
|
|
215
|
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
216
|
+
<AlertDialogAction onClick={disableAccount}>Disable</AlertDialogAction>
|
|
217
|
+
</AlertDialogFooter>
|
|
218
|
+
</AlertDialogContent>
|
|
219
|
+
</AlertDialog>
|
|
220
|
+
) : (
|
|
221
|
+
<Button size="sm" onClick={enableAccount} disabled={savingStatus}>
|
|
222
|
+
{savingStatus ? 'Saving…' : 'Enable account'}
|
|
223
|
+
</Button>
|
|
224
|
+
)}
|
|
225
|
+
<AlertDialog>
|
|
226
|
+
<AlertDialogTrigger asChild>
|
|
227
|
+
<Button variant="outline" size="sm">
|
|
228
|
+
Copy reset-password link
|
|
229
|
+
</Button>
|
|
230
|
+
</AlertDialogTrigger>
|
|
231
|
+
<AlertDialogContent>
|
|
232
|
+
<AlertDialogHeader>
|
|
233
|
+
<AlertDialogTitle>Generate reset link for {user.email}?</AlertDialogTitle>
|
|
234
|
+
<AlertDialogDescription>
|
|
235
|
+
A one-time password-reset URL will be copied to your clipboard.
|
|
236
|
+
</AlertDialogDescription>
|
|
237
|
+
</AlertDialogHeader>
|
|
238
|
+
<AlertDialogFooter>
|
|
239
|
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
240
|
+
<AlertDialogAction onClick={copyResetLink}>Generate</AlertDialogAction>
|
|
241
|
+
</AlertDialogFooter>
|
|
242
|
+
</AlertDialogContent>
|
|
243
|
+
</AlertDialog>
|
|
244
|
+
</div>
|
|
245
|
+
</CardContent>
|
|
246
|
+
</Card>
|
|
247
|
+
|
|
248
|
+
<Card>
|
|
249
|
+
<CardHeader>
|
|
250
|
+
<CardTitle className="text-base">Roles</CardTitle>
|
|
251
|
+
</CardHeader>
|
|
252
|
+
<CardContent className="space-y-4">
|
|
253
|
+
<div className="flex flex-col gap-2">
|
|
254
|
+
{roles.map((role) => (
|
|
255
|
+
<div key={role.id} className="flex items-center gap-2">
|
|
256
|
+
<Checkbox
|
|
257
|
+
id={`role-${role.id}`}
|
|
258
|
+
checked={selectedRoles.includes(role.name)}
|
|
259
|
+
onCheckedChange={() => toggleRole(role.name)}
|
|
260
|
+
/>
|
|
261
|
+
<Label htmlFor={`role-${role.id}`} className="cursor-pointer font-normal">
|
|
262
|
+
{role.name}
|
|
263
|
+
</Label>
|
|
264
|
+
</div>
|
|
265
|
+
))}
|
|
266
|
+
</div>
|
|
267
|
+
<div className="flex gap-2">
|
|
268
|
+
<Button size="sm" onClick={handleSaveRoles} disabled={savingRoles}>
|
|
269
|
+
{savingRoles ? 'Saving…' : 'Save roles'}
|
|
270
|
+
</Button>
|
|
271
|
+
<Button size="sm" variant="ghost" onClick={() => router.reload()}>
|
|
272
|
+
Discard
|
|
273
|
+
</Button>
|
|
274
|
+
</div>
|
|
275
|
+
</CardContent>
|
|
276
|
+
</Card>
|
|
277
|
+
|
|
278
|
+
{has_permissions_module && (
|
|
279
|
+
<Link
|
|
280
|
+
href={`/permissions/users/${user.id}`}
|
|
281
|
+
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
|
|
282
|
+
>
|
|
283
|
+
<ShieldCheck className="size-4" />
|
|
284
|
+
Manage permissions →
|
|
285
|
+
</Link>
|
|
286
|
+
)}
|
|
287
|
+
</div>
|
|
288
|
+
</PageShell>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
Edit.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
|
|
293
|
+
export default Edit;
|