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