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,296 @@
1
+ import { Link, router, 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 } from '@simple-module-py/ui/components/ui/card';
6
+ import { Input } from '@simple-module-py/ui/components/ui/input';
7
+ import {
8
+ Table,
9
+ TableBody,
10
+ TableCell,
11
+ TableHead,
12
+ TableHeader,
13
+ TableRow,
14
+ } from '@simple-module-py/ui/components/ui/table';
15
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@simple-module-py/ui/components/ui/tabs';
16
+ import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
17
+ import { ArrowDown, ArrowUp, Pencil, Plus, Search, ShieldCheck, Users } from 'lucide-react';
18
+ import { useCallback, useEffect, useMemo, useState } from 'react';
19
+ import { type Filters, IndexFilters } from '../../components/IndexFilters';
20
+ import { type RoleItem, RolesTab } from '../../components/RolesTab';
21
+
22
+ interface UserListItem {
23
+ id: string;
24
+ email: string;
25
+ full_name: string | null;
26
+ is_active: boolean;
27
+ is_verified: boolean;
28
+ last_login_at: string | null;
29
+ created_at: string | null;
30
+ roles: string[];
31
+ }
32
+
33
+ interface Pagination {
34
+ page: number;
35
+ per_page: number;
36
+ total: number;
37
+ }
38
+
39
+ interface Props {
40
+ users: UserListItem[];
41
+ pagination: Pagination;
42
+ query: string;
43
+ roles: RoleItem[];
44
+ filters: Filters;
45
+ }
46
+
47
+ const DEFAULT_FILTERS: Filters = {
48
+ status: 'all',
49
+ role: '',
50
+ verified: 'all',
51
+ sort: 'email',
52
+ order: 'asc',
53
+ };
54
+
55
+ function SortIcon({ col, filters }: { col: Filters['sort']; filters: Filters }) {
56
+ if (filters.sort !== col) return null;
57
+ return filters.order === 'asc' ? (
58
+ <ArrowUp className="inline-block ml-1 size-3" />
59
+ ) : (
60
+ <ArrowDown className="inline-block ml-1 size-3" />
61
+ );
62
+ }
63
+
64
+ function Index() {
65
+ const {
66
+ users,
67
+ pagination,
68
+ query: initialQuery,
69
+ roles,
70
+ filters: serverFilters,
71
+ } = usePage<{ props: Props }>().props as unknown as Props;
72
+
73
+ const filters: Filters = useMemo(
74
+ () => ({ ...DEFAULT_FILTERS, ...serverFilters }),
75
+ [serverFilters],
76
+ );
77
+ const [search, setSearch] = useState(initialQuery ?? '');
78
+
79
+ const navigate = useCallback(
80
+ (next: Partial<{ page: number; q: string } & Filters>) => {
81
+ const params: Record<string, string> = {};
82
+ const q = next.q ?? search;
83
+ const status = next.status ?? filters.status;
84
+ const role = next.role ?? filters.role;
85
+ const verified = next.verified ?? filters.verified;
86
+ const sort = next.sort ?? filters.sort;
87
+ const order = next.order ?? filters.order;
88
+ const page = next.page ?? 1;
89
+ if (q) params.q = q;
90
+ if (status !== 'all') params.status = status;
91
+ if (role) params.role = role;
92
+ if (verified !== 'all') params.verified = verified;
93
+ if (sort !== 'email') params.sort = sort;
94
+ if (order !== 'asc') params.order = order;
95
+ if (page > 1) params.page = String(page);
96
+ router.get('/users/admin', params, { preserveState: true, preserveScroll: true });
97
+ },
98
+ [search, filters],
99
+ );
100
+
101
+ const toggleSort = (col: Filters['sort']) => {
102
+ if (filters.sort === col) {
103
+ navigate({ order: filters.order === 'asc' ? 'desc' : 'asc' });
104
+ } else {
105
+ navigate({ sort: col, order: 'asc' });
106
+ }
107
+ };
108
+
109
+ useEffect(() => {
110
+ if (search === (initialQuery ?? '')) return;
111
+ const timeout = setTimeout(() => navigate({ q: search }), 300);
112
+ return () => clearTimeout(timeout);
113
+ }, [search, initialQuery, navigate]);
114
+
115
+ const totalPages = Math.ceil(pagination.total / pagination.per_page);
116
+
117
+ return (
118
+ <PageShell title="Users & Roles" description="Manage user accounts, roles, and permissions.">
119
+ <Tabs defaultValue="users" className="space-y-4">
120
+ <TabsList>
121
+ <TabsTrigger value="users">
122
+ <Users className="size-4" />
123
+ Users
124
+ <Badge variant="secondary" className="ml-1">
125
+ {pagination.total}
126
+ </Badge>
127
+ </TabsTrigger>
128
+ <TabsTrigger value="roles">
129
+ <ShieldCheck className="size-4" />
130
+ Roles
131
+ <Badge variant="secondary" className="ml-1">
132
+ {roles.length}
133
+ </Badge>
134
+ </TabsTrigger>
135
+ </TabsList>
136
+
137
+ <TabsContent value="users">
138
+ <div className="mb-4 flex flex-wrap items-center justify-between gap-4">
139
+ <div className="relative max-w-sm flex-1">
140
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
141
+ <Input
142
+ placeholder="Search by email or name…"
143
+ value={search}
144
+ onChange={(e) => setSearch(e.target.value)}
145
+ className="pl-9"
146
+ />
147
+ </div>
148
+ <IndexFilters
149
+ filters={filters}
150
+ roles={roles.map((r) => r.name)}
151
+ onChange={(next) => navigate(next)}
152
+ />
153
+ <Button asChild>
154
+ <Link href="/users/admin/invite">
155
+ <Plus />
156
+ Invite user
157
+ </Link>
158
+ </Button>
159
+ </div>
160
+
161
+ <Card>
162
+ <Table>
163
+ <TableHeader>
164
+ <TableRow>
165
+ <TableHead>
166
+ <button
167
+ type="button"
168
+ className="flex items-center gap-0.5 hover:text-foreground"
169
+ onClick={() => toggleSort('email')}
170
+ >
171
+ Email
172
+ <SortIcon col="email" filters={filters} />
173
+ </button>
174
+ </TableHead>
175
+ <TableHead className="hidden md:table-cell">Name</TableHead>
176
+ <TableHead className="hidden sm:table-cell">Roles</TableHead>
177
+ <TableHead className="hidden sm:table-cell">Status</TableHead>
178
+ <TableHead className="hidden lg:table-cell">
179
+ <button
180
+ type="button"
181
+ className="flex items-center gap-0.5 hover:text-foreground"
182
+ onClick={() => toggleSort('last_login_at')}
183
+ >
184
+ Last login
185
+ <SortIcon col="last_login_at" filters={filters} />
186
+ </button>
187
+ </TableHead>
188
+ <TableHead className="hidden lg:table-cell">
189
+ <button
190
+ type="button"
191
+ className="flex items-center gap-0.5 hover:text-foreground"
192
+ onClick={() => toggleSort('created_at')}
193
+ >
194
+ Created
195
+ <SortIcon col="created_at" filters={filters} />
196
+ </button>
197
+ </TableHead>
198
+ <TableHead className="text-right">Actions</TableHead>
199
+ </TableRow>
200
+ </TableHeader>
201
+ <TableBody>
202
+ {users.map((user) => (
203
+ <TableRow key={user.id}>
204
+ <TableCell>
205
+ <div>
206
+ <span className="font-medium">{user.email}</span>
207
+ {!user.is_verified && (
208
+ <Badge variant="outline" className="ml-2 text-xs">
209
+ unverified
210
+ </Badge>
211
+ )}
212
+ </div>
213
+ </TableCell>
214
+ <TableCell className="hidden md:table-cell text-muted-foreground text-sm">
215
+ {user.full_name || '—'}
216
+ </TableCell>
217
+ <TableCell className="hidden sm:table-cell">
218
+ <div className="flex flex-wrap gap-1">
219
+ {user.roles.length > 0 ? (
220
+ user.roles.map((r) => (
221
+ <Badge key={r} variant="secondary">
222
+ {r}
223
+ </Badge>
224
+ ))
225
+ ) : (
226
+ <span className="text-muted-foreground text-sm">—</span>
227
+ )}
228
+ </div>
229
+ </TableCell>
230
+ <TableCell className="hidden sm:table-cell">
231
+ <Badge variant={user.is_active ? 'secondary' : 'destructive'}>
232
+ {user.is_active ? 'Active' : 'Disabled'}
233
+ </Badge>
234
+ </TableCell>
235
+ <TableCell className="hidden lg:table-cell text-sm text-muted-foreground">
236
+ {user.last_login_at ? new Date(user.last_login_at).toLocaleDateString() : '—'}
237
+ </TableCell>
238
+ <TableCell className="hidden lg:table-cell text-sm text-muted-foreground">
239
+ {user.created_at ? new Date(user.created_at).toLocaleDateString() : '—'}
240
+ </TableCell>
241
+ <TableCell className="text-right">
242
+ <Button asChild variant="ghost" size="icon-sm">
243
+ <Link href={`/users/admin/${user.id}`}>
244
+ <Pencil />
245
+ </Link>
246
+ </Button>
247
+ </TableCell>
248
+ </TableRow>
249
+ ))}
250
+ {users.length === 0 && (
251
+ <TableRow>
252
+ <TableCell colSpan={7} className="h-32 text-center">
253
+ <div className="flex flex-col items-center gap-2 text-muted-foreground">
254
+ <Users className="size-8" />
255
+ <p>{search ? `No users match "${search}"` : 'No users yet'}</p>
256
+ </div>
257
+ </TableCell>
258
+ </TableRow>
259
+ )}
260
+ </TableBody>
261
+ </Table>
262
+ </Card>
263
+
264
+ {totalPages > 1 && (
265
+ <div className="mt-4 flex items-center justify-center gap-2">
266
+ <Button
267
+ variant="outline"
268
+ size="sm"
269
+ disabled={pagination.page <= 1}
270
+ onClick={() => navigate({ page: pagination.page - 1 })}
271
+ >
272
+ Previous
273
+ </Button>
274
+ <span className="text-sm text-muted-foreground">
275
+ Page {pagination.page} of {totalPages}
276
+ </span>
277
+ <Button
278
+ variant="outline"
279
+ size="sm"
280
+ disabled={pagination.page >= totalPages}
281
+ onClick={() => navigate({ page: pagination.page + 1 })}
282
+ >
283
+ Next
284
+ </Button>
285
+ </div>
286
+ )}
287
+ </TabsContent>
288
+
289
+ <RolesTab roles={roles} />
290
+ </Tabs>
291
+ </PageShell>
292
+ );
293
+ }
294
+
295
+ Index.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
296
+ export default Index;
@@ -0,0 +1,135 @@
1
+ import { Link, router, usePage } from '@inertiajs/react';
2
+ import { PageShell } from '@simple-module-py/ui/components/PageShell';
3
+ import { Button } from '@simple-module-py/ui/components/ui/button';
4
+ import { Card, CardContent } from '@simple-module-py/ui/components/ui/card';
5
+ import { Checkbox } from '@simple-module-py/ui/components/ui/checkbox';
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 Role {
13
+ id: string;
14
+ name: string;
15
+ }
16
+
17
+ interface Props {
18
+ roles: Role[];
19
+ }
20
+
21
+ function Invite() {
22
+ const { roles } = usePage<{ props: Props }>().props as unknown as Props;
23
+
24
+ const [email, setEmail] = useState('');
25
+ const [fullName, setFullName] = useState('');
26
+ const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
27
+ const [error, setError] = useState<string | null>(null);
28
+ const [loading, setLoading] = useState(false);
29
+
30
+ const toggleRole = (roleName: string) => {
31
+ setSelectedRoles((prev) =>
32
+ prev.includes(roleName) ? prev.filter((r) => r !== roleName) : [...prev, roleName],
33
+ );
34
+ };
35
+
36
+ const handleSubmit = (e: React.FormEvent) => {
37
+ e.preventDefault();
38
+ setError(null);
39
+ setLoading(true);
40
+ fetch('/api/users/admin/invite', {
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/json' },
43
+ body: JSON.stringify({ email, full_name: fullName || null, role_names: selectedRoles }),
44
+ })
45
+ .then(async (res) => {
46
+ if (res.ok) {
47
+ toast.success('Invite sent');
48
+ router.visit('/users/admin');
49
+ } else {
50
+ const data = await res.json().catch(() => ({}));
51
+ setError(typeof data?.detail === 'string' ? data.detail : 'Failed to send invite');
52
+ }
53
+ })
54
+ .catch(() => setError('An error occurred. Please try again.'))
55
+ .finally(() => setLoading(false));
56
+ };
57
+
58
+ return (
59
+ <PageShell
60
+ title="Invite user"
61
+ description="Send an invitation email to a new user"
62
+ actions={
63
+ <Button asChild variant="outline">
64
+ <Link href="/users/admin">Back to Users</Link>
65
+ </Button>
66
+ }
67
+ >
68
+ <Card className="max-w-xl">
69
+ <CardContent className="pt-6">
70
+ <form onSubmit={handleSubmit} className="space-y-5">
71
+ <div className="space-y-2">
72
+ <Label htmlFor="email">
73
+ Email <span className="text-destructive">*</span>
74
+ </Label>
75
+ <Input
76
+ id="email"
77
+ type="email"
78
+ value={email}
79
+ onChange={(e) => setEmail(e.target.value)}
80
+ placeholder="user@example.com"
81
+ required
82
+ autoComplete="off"
83
+ />
84
+ </div>
85
+
86
+ <div className="space-y-2">
87
+ <Label htmlFor="full_name">Full name</Label>
88
+ <Input
89
+ id="full_name"
90
+ type="text"
91
+ value={fullName}
92
+ onChange={(e) => setFullName(e.target.value)}
93
+ placeholder="Optional"
94
+ />
95
+ </div>
96
+
97
+ {roles.length > 0 && (
98
+ <div className="space-y-2">
99
+ <Label>Roles</Label>
100
+ <div className="flex flex-col gap-2">
101
+ {roles.map((role) => (
102
+ <div key={role.id} className="flex items-center gap-2">
103
+ <Checkbox
104
+ id={`role-${role.id}`}
105
+ checked={selectedRoles.includes(role.name)}
106
+ onCheckedChange={() => toggleRole(role.name)}
107
+ />
108
+ <Label htmlFor={`role-${role.id}`} className="cursor-pointer font-normal">
109
+ {role.name}
110
+ </Label>
111
+ </div>
112
+ ))}
113
+ </div>
114
+ </div>
115
+ )}
116
+
117
+ {error && <p className="text-sm text-destructive">{error}</p>}
118
+
119
+ <div className="pt-2 flex gap-3">
120
+ <Button type="submit" disabled={loading}>
121
+ {loading ? 'Sending…' : 'Send invite'}
122
+ </Button>
123
+ <Button asChild variant="outline">
124
+ <Link href="/users/admin">Cancel</Link>
125
+ </Button>
126
+ </div>
127
+ </form>
128
+ </CardContent>
129
+ </Card>
130
+ </PageShell>
131
+ );
132
+ }
133
+
134
+ Invite.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
135
+ export default Invite;
@@ -0,0 +1,110 @@
1
+ import { 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 { AuthCardShell } from '@simple-module-py/ui/layouts/AuthCardShell';
11
+ import { useEffect, useState } from 'react';
12
+
13
+ interface Props {
14
+ token: string;
15
+ }
16
+
17
+ type VerifyStatus = 'pending' | 'success' | 'already_verified' | 'error';
18
+
19
+ function VerifyEmail() {
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 [status, setStatus] = useState<VerifyStatus>('pending');
28
+ const [errorMsg, setErrorMsg] = useState('');
29
+
30
+ useEffect(() => {
31
+ if (!token) {
32
+ setStatus('error');
33
+ setErrorMsg('No verification token found in this link.');
34
+ return;
35
+ }
36
+
37
+ fetch('/api/users/auth/verify', {
38
+ method: 'POST',
39
+ headers: { 'Content-Type': 'application/json' },
40
+ body: JSON.stringify({ token }),
41
+ })
42
+ .then(async (res) => {
43
+ if (res.status === 200 || res.status === 204) {
44
+ setStatus('success');
45
+ } else {
46
+ const data = await res.json().catch(() => ({}));
47
+ const detail = typeof data?.detail === 'string' ? data.detail : '';
48
+ if (detail === 'VERIFY_USER_ALREADY_VERIFIED') {
49
+ setStatus('already_verified');
50
+ } else {
51
+ setStatus('error');
52
+ setErrorMsg('Verification link expired or invalid. Please request a new one.');
53
+ }
54
+ }
55
+ })
56
+ .catch(() => {
57
+ setStatus('error');
58
+ setErrorMsg('An error occurred. Please try again.');
59
+ });
60
+ }, [token]);
61
+
62
+ const content = {
63
+ pending: {
64
+ title: 'Verifying your email…',
65
+ description: 'Please wait while we verify your email address.',
66
+ body: null,
67
+ },
68
+ success: {
69
+ title: 'Email verified!',
70
+ description: 'Your account is now active. You can sign in.',
71
+ body: (
72
+ <a href="/users/login">
73
+ <Button className="w-full">Sign in</Button>
74
+ </a>
75
+ ),
76
+ },
77
+ already_verified: {
78
+ title: 'Already verified',
79
+ description: 'This account is already verified — you can log in.',
80
+ body: (
81
+ <a href="/users/login">
82
+ <Button className="w-full">Sign in</Button>
83
+ </a>
84
+ ),
85
+ },
86
+ error: {
87
+ title: 'Verification failed',
88
+ description: errorMsg || 'Verification link expired or invalid.',
89
+ body: (
90
+ <a href="/users/login" className="text-sm text-primary hover:underline">
91
+ Back to sign in
92
+ </a>
93
+ ),
94
+ },
95
+ }[status];
96
+
97
+ return (
98
+ <AuthCardShell>
99
+ <Card className="w-full max-w-sm">
100
+ <CardHeader>
101
+ <CardTitle>{content.title}</CardTitle>
102
+ <CardDescription>{content.description}</CardDescription>
103
+ </CardHeader>
104
+ {content.body && <CardContent>{content.body}</CardContent>}
105
+ </Card>
106
+ </AuthCardShell>
107
+ );
108
+ }
109
+
110
+ export default VerifyEmail;
users/py.typed ADDED
File without changes
users/rate_limit.py ADDED
@@ -0,0 +1,59 @@
1
+ """In-process rate limiters — TTL caches, no Redis.
2
+
3
+ Note: counters live in the worker process, so a multi-worker deployment (e.g.
4
+ ``uvicorn --workers 4``) has independent counters per worker. Effective
5
+ thresholds scale with worker count. Swap for a Redis-backed store when you
6
+ deploy behind more than a single worker.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from cachetools import TTLCache
12
+
13
+
14
+ class LoginRateLimiter:
15
+ """Per-key failure counter with a cooldown window after N failures."""
16
+
17
+ def __init__(
18
+ self,
19
+ max_failures: int = 5,
20
+ window_seconds: int = 300,
21
+ cooldown_seconds: int = 900,
22
+ ) -> None:
23
+ self._fails: TTLCache = TTLCache(maxsize=10_000, ttl=window_seconds)
24
+ self._locks: TTLCache = TTLCache(maxsize=10_000, ttl=cooldown_seconds)
25
+ self._max = max_failures
26
+
27
+ def is_locked(self, key: str) -> bool:
28
+ return key in self._locks
29
+
30
+ def record_failure(self, key: str) -> None:
31
+ count = self._fails.get(key, 0) + 1
32
+ self._fails[key] = count
33
+ if count >= self._max:
34
+ self._locks[key] = True
35
+ self._fails.pop(key, None)
36
+
37
+ def reset(self, key: str) -> None:
38
+ self._fails.pop(key, None)
39
+ self._locks.pop(key, None)
40
+
41
+
42
+ class ThroughputLimiter:
43
+ """N requests per rolling window per key — counts every attempt.
44
+
45
+ Used to dampen enumeration and email-spam on endpoints like
46
+ ``/forgot-password`` and ``/register`` where a failure-based lockout
47
+ (``LoginRateLimiter``) isn't the right shape — the attacker is after the
48
+ side-effect itself, not a correct credential.
49
+ """
50
+
51
+ def __init__(self, max_attempts: int = 10, window_seconds: int = 300) -> None:
52
+ self._hits: TTLCache = TTLCache(maxsize=10_000, ttl=window_seconds)
53
+ self._max = max_attempts
54
+
55
+ def check_and_record(self, key: str) -> bool:
56
+ """Return True if this attempt is within budget; False if throttled."""
57
+ count = self._hits.get(key, 0) + 1
58
+ self._hits[key] = count
59
+ return count <= self._max
users/roles_cache.py ADDED
@@ -0,0 +1,58 @@
1
+ """Cached list of roles for admin-page rendering.
2
+
3
+ Roles are seed data — created by the ``e3ce9754e6dc_seed_users_roles``
4
+ migration and only mutated by hand. Caching on ``app.state`` lets admin views
5
+ build Inertia payloads without a per-request ``SELECT * FROM users_role``.
6
+
7
+ The cache stores detached :class:`RoleSummary` values (not ORM objects, which
8
+ would blow up on attribute access after their session closes). Each view is
9
+ responsible for shaping them into whatever the page needs.
10
+
11
+ Refresh entry points:
12
+
13
+ * ``UsersModule.on_startup`` — initial population.
14
+ * ``refresh_roles_cache(app)`` — callable after any future role-management
15
+ code paths that add/remove roles.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+ from typing import TYPE_CHECKING
22
+
23
+ from sqlalchemy import select
24
+
25
+ from users.models import Role
26
+
27
+ if TYPE_CHECKING:
28
+ from fastapi import FastAPI
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class RoleSummary:
33
+ """Minimal role projection safe to hold across request boundaries."""
34
+
35
+ id: str
36
+ name: str
37
+
38
+
39
+ async def refresh_roles_cache(app: FastAPI) -> list[RoleSummary]:
40
+ """Reload the roles list from the DB into ``app.state.users.roles_cache``."""
41
+ async with app.state.sm.db.session_factory() as db:
42
+ result = await db.execute(select(Role).order_by(Role.name))
43
+ cached = [RoleSummary(id=str(r.id), name=r.name) for r in result.scalars().all()]
44
+ app.state.users.roles_cache = cached
45
+ return cached
46
+
47
+
48
+ async def get_roles_cache(app: FastAPI) -> list[RoleSummary]:
49
+ """Return the cached roles list, populating it from the DB on first miss.
50
+
51
+ The cache is pre-warmed in ``UsersModule.on_startup``. The lazy fallback
52
+ covers scenarios where startup ran before the ``users_role`` table had any
53
+ rows. Once populated, subsequent calls are O(1) attribute reads.
54
+ """
55
+ cached = app.state.users.roles_cache
56
+ if cached:
57
+ return cached
58
+ return await refresh_roles_cache(app)