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,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;