simple-module-users 0.0.9__tar.gz → 0.0.11__tar.gz
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.9 → simple_module_users-0.0.11}/PKG-INFO +6 -6
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/pyproject.toml +6 -6
- simple_module_users-0.0.11/users/components/RolesTab.tsx +77 -0
- simple_module_users-0.0.11/users/components/UserRow.tsx +80 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/endpoints/views.py +2 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/pages/AcceptInvite.tsx +49 -43
- simple_module_users-0.0.11/users/pages/ForgotPassword.tsx +86 -0
- simple_module_users-0.0.11/users/pages/Login.tsx +188 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/pages/Profile.tsx +51 -15
- simple_module_users-0.0.11/users/pages/Register.tsx +158 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/pages/ResetPassword.tsx +44 -49
- simple_module_users-0.0.11/users/pages/Users/Edit.tsx +235 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/pages/Users/Index.tsx +60 -89
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/pages/Users/Invite.tsx +45 -33
- simple_module_users-0.0.11/users/pages/Users/components/AccountStatusCard.tsx +102 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/pages/VerifyEmail.tsx +29 -19
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/service.py +14 -0
- simple_module_users-0.0.9/users/components/RolesTab.tsx +0 -72
- simple_module_users-0.0.9/users/pages/ForgotPassword.tsx +0 -90
- simple_module_users-0.0.9/users/pages/Login.tsx +0 -183
- simple_module_users-0.0.9/users/pages/Register.tsx +0 -152
- simple_module_users-0.0.9/users/pages/Users/Edit.tsx +0 -293
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/.gitignore +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/LICENSE +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/README.md +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/package.json +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/_middleware_support.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/conftest.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_api_admin.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_api_auth.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_bootstrap.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_cli.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_invite_flow.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_mailer.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_rate_limit.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_service_admin.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_settings.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_user_manager.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_user_model.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_user_role_model.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_user_service.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_users_middleware.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_views.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_views_admin.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tsconfig.json +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/__init__.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/backend.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/bootstrap.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/cli.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/components/IndexFilters.tsx +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/constants.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/contracts/events.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/contracts/schemas.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/db_adapter.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/deps.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/endpoints/__init__.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/endpoints/api.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/endpoints/api_admin.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/exceptions.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/mailer/__init__.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/mailer/console.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/mailer/smtp.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/mailer/templates/invite.txt +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/mailer/templates/reset_password.txt +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/mailer/templates/verify_email.txt +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/manager.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/middleware.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/models/__init__.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/models/_base.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/models/access_token.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/models/role.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/models/user.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/models/user_role.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/module.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/py.typed +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/rate_limit.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/roles_cache.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/settings.py +0 -0
- {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/state.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simple_module_users
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.11
|
|
4
4
|
Summary: Email + password user management, admin invites, RBAC-ready — replaces Keycloak for simple_module apps
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -24,11 +24,11 @@ Requires-Python: >=3.12
|
|
|
24
24
|
Requires-Dist: aiosmtplib>=3.0
|
|
25
25
|
Requires-Dist: cachetools>=5.3
|
|
26
26
|
Requires-Dist: fastapi-users[sqlalchemy]<16,>=15
|
|
27
|
-
Requires-Dist: simple-module-auth==0.0.
|
|
28
|
-
Requires-Dist: simple-module-core==0.0.
|
|
29
|
-
Requires-Dist: simple-module-db==0.0.
|
|
30
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
31
|
-
Requires-Dist: simple-module-settings==0.0.
|
|
27
|
+
Requires-Dist: simple-module-auth==0.0.11
|
|
28
|
+
Requires-Dist: simple-module-core==0.0.11
|
|
29
|
+
Requires-Dist: simple-module-db==0.0.11
|
|
30
|
+
Requires-Dist: simple-module-hosting==0.0.11
|
|
31
|
+
Requires-Dist: simple-module-settings==0.0.11
|
|
32
32
|
Requires-Dist: typer>=0.12
|
|
33
33
|
Description-Content-Type: text/markdown
|
|
34
34
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_users"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.11"
|
|
4
4
|
description = "Email + password user management, admin invites, RBAC-ready — replaces Keycloak for simple_module apps"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -21,11 +21,11 @@ classifiers = [
|
|
|
21
21
|
"Typing :: Typed",
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
|
-
"simple_module_core==0.0.
|
|
25
|
-
"simple_module_db==0.0.
|
|
26
|
-
"simple_module_hosting==0.0.
|
|
27
|
-
"simple_module_settings==0.0.
|
|
28
|
-
"simple_module_auth==0.0.
|
|
24
|
+
"simple_module_core==0.0.11",
|
|
25
|
+
"simple_module_db==0.0.11",
|
|
26
|
+
"simple_module_hosting==0.0.11",
|
|
27
|
+
"simple_module_settings==0.0.11",
|
|
28
|
+
"simple_module_auth==0.0.11",
|
|
29
29
|
# Pinned to a narrow range: `deps.py` relies on mutating CookieTransport
|
|
30
30
|
# fields after construction (see reconfigure_cookie_transport in backend.py).
|
|
31
31
|
# Bumping the major version requires re-checking those field names.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Link } from '@inertiajs/react';
|
|
2
|
+
import { Badge } from '@simple-module-py/ui/components/ui/badge';
|
|
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 { TabsContent } from '@simple-module-py/ui/components/ui/tabs';
|
|
6
|
+
import { Pencil, ShieldCheck, Users } from 'lucide-react';
|
|
7
|
+
|
|
8
|
+
export interface RoleItem {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
description?: string | null;
|
|
12
|
+
user_count: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const SYSTEM_ROLES = new Set(['Owner', 'Admin', 'Viewer']);
|
|
16
|
+
|
|
17
|
+
export function RolesTab({ roles }: { roles: RoleItem[] }) {
|
|
18
|
+
return (
|
|
19
|
+
<TabsContent value="roles">
|
|
20
|
+
{roles.length === 0 ? (
|
|
21
|
+
<Card className="border-border">
|
|
22
|
+
<CardContent className="flex flex-col items-center gap-2 py-16 text-muted-foreground">
|
|
23
|
+
<ShieldCheck className="size-8" />
|
|
24
|
+
<p>No roles defined</p>
|
|
25
|
+
</CardContent>
|
|
26
|
+
</Card>
|
|
27
|
+
) : (
|
|
28
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
29
|
+
{roles.map((role) => {
|
|
30
|
+
const isSystem = SYSTEM_ROLES.has(role.name);
|
|
31
|
+
return (
|
|
32
|
+
<Card key={role.id} className="border-border">
|
|
33
|
+
<CardContent className="pt-5">
|
|
34
|
+
<div className="flex items-start gap-3">
|
|
35
|
+
<span className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary-600/10 text-primary-700">
|
|
36
|
+
{isSystem ? (
|
|
37
|
+
<ShieldCheck className="h-[18px] w-[18px]" aria-hidden="true" />
|
|
38
|
+
) : (
|
|
39
|
+
<Users className="h-[18px] w-[18px]" aria-hidden="true" />
|
|
40
|
+
)}
|
|
41
|
+
</span>
|
|
42
|
+
<div className="flex-1 min-w-0">
|
|
43
|
+
<div className="flex items-center gap-2">
|
|
44
|
+
<h3 className="text-[15px] font-bold tracking-tight font-[var(--font-display)] text-foreground">
|
|
45
|
+
{role.name}
|
|
46
|
+
</h3>
|
|
47
|
+
{isSystem && (
|
|
48
|
+
<Badge
|
|
49
|
+
variant="outline"
|
|
50
|
+
className="border-border bg-secondary text-[10px] text-muted-foreground"
|
|
51
|
+
>
|
|
52
|
+
system
|
|
53
|
+
</Badge>
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">
|
|
57
|
+
{role.description || 'No description.'}
|
|
58
|
+
</p>
|
|
59
|
+
<div className="mt-2 font-mono text-[11px] text-muted-foreground">
|
|
60
|
+
{role.user_count} {role.user_count === 1 ? 'member' : 'members'}
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
<Button asChild variant="ghost" size="icon-sm">
|
|
64
|
+
<Link href={`/permissions/roles/${role.id}/edit`} aria-label="Edit role">
|
|
65
|
+
<Pencil />
|
|
66
|
+
</Link>
|
|
67
|
+
</Button>
|
|
68
|
+
</div>
|
|
69
|
+
</CardContent>
|
|
70
|
+
</Card>
|
|
71
|
+
);
|
|
72
|
+
})}
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
</TabsContent>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Link } from '@inertiajs/react';
|
|
2
|
+
import { Badge } from '@simple-module-py/ui/components/ui/badge';
|
|
3
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
4
|
+
import { TableCell, TableRow } from '@simple-module-py/ui/components/ui/table';
|
|
5
|
+
import { Pencil } from 'lucide-react';
|
|
6
|
+
|
|
7
|
+
export interface UserListItem {
|
|
8
|
+
id: string;
|
|
9
|
+
email: string;
|
|
10
|
+
full_name: string | null;
|
|
11
|
+
is_active: boolean;
|
|
12
|
+
is_verified: boolean;
|
|
13
|
+
last_login_at: string | null;
|
|
14
|
+
created_at: string | null;
|
|
15
|
+
roles: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function Avatar({ initial }: { initial: string }) {
|
|
19
|
+
return (
|
|
20
|
+
<span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-primary-600 to-primary-800 text-[13px] font-bold text-white font-[var(--font-display)]">
|
|
21
|
+
{initial}
|
|
22
|
+
</span>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function StatusBadge({ user }: { user: UserListItem }) {
|
|
27
|
+
if (!user.is_active) {
|
|
28
|
+
return (
|
|
29
|
+
<Badge variant="outline" className="border-border bg-secondary text-muted-foreground">
|
|
30
|
+
disabled
|
|
31
|
+
</Badge>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
if (!user.is_verified) {
|
|
35
|
+
return (
|
|
36
|
+
<Badge variant="outline" className="border-blue-200 bg-blue-50 text-blue-700">
|
|
37
|
+
invited
|
|
38
|
+
</Badge>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return (
|
|
42
|
+
<Badge variant="outline" className="border-primary-200 bg-primary-50 text-primary-700">
|
|
43
|
+
active
|
|
44
|
+
</Badge>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function UserRow({ user }: { user: UserListItem }) {
|
|
49
|
+
return (
|
|
50
|
+
<TableRow className="hover:bg-secondary/40">
|
|
51
|
+
<TableCell className="py-3">
|
|
52
|
+
<div className="flex items-center gap-3">
|
|
53
|
+
<Avatar initial={(user.full_name || user.email).charAt(0).toUpperCase()} />
|
|
54
|
+
<div className="min-w-0">
|
|
55
|
+
<div className="truncate text-sm font-semibold text-foreground">
|
|
56
|
+
{user.full_name || user.email.split('@')[0]}
|
|
57
|
+
</div>
|
|
58
|
+
<div className="truncate text-[12px] text-muted-foreground">{user.email}</div>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</TableCell>
|
|
62
|
+
<TableCell className="hidden sm:table-cell text-sm text-muted-foreground">
|
|
63
|
+
{user.roles.length > 0 ? user.roles.join(', ') : '—'}
|
|
64
|
+
</TableCell>
|
|
65
|
+
<TableCell className="hidden sm:table-cell">
|
|
66
|
+
<StatusBadge user={user} />
|
|
67
|
+
</TableCell>
|
|
68
|
+
<TableCell className="hidden lg:table-cell text-sm text-muted-foreground">
|
|
69
|
+
{user.last_login_at ? new Date(user.last_login_at).toLocaleDateString() : '—'}
|
|
70
|
+
</TableCell>
|
|
71
|
+
<TableCell className="text-right">
|
|
72
|
+
<Button asChild variant="ghost" size="icon-sm">
|
|
73
|
+
<Link href={`/users/admin/${user.id}`}>
|
|
74
|
+
<Pencil />
|
|
75
|
+
</Link>
|
|
76
|
+
</Button>
|
|
77
|
+
</TableCell>
|
|
78
|
+
</TableRow>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -159,11 +159,13 @@ async def admin_index(
|
|
|
159
159
|
order=clean_order,
|
|
160
160
|
)
|
|
161
161
|
roles = await service.list_roles()
|
|
162
|
+
aggregates = await service.count_user_states()
|
|
162
163
|
return await inertia.render(
|
|
163
164
|
_PAGE_ADMIN_INDEX,
|
|
164
165
|
{
|
|
165
166
|
"users": [u.model_dump(mode="json") for u in users],
|
|
166
167
|
"pagination": {"page": page, "per_page": per_page, "total": total},
|
|
168
|
+
"aggregates": aggregates,
|
|
167
169
|
"query": q or "",
|
|
168
170
|
"roles": [r.model_dump(mode="json") for r in roles],
|
|
169
171
|
"filters": {
|
|
@@ -1,15 +1,9 @@
|
|
|
1
1
|
import { router, usePage } from '@inertiajs/react';
|
|
2
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
3
|
import { Input } from '@simple-module-py/ui/components/ui/input';
|
|
11
4
|
import { Label } from '@simple-module-py/ui/components/ui/label';
|
|
12
5
|
import { AuthCardShell } from '@simple-module-py/ui/layouts/AuthCardShell';
|
|
6
|
+
import { CheckCircle2 } from 'lucide-react';
|
|
13
7
|
import { useState } from 'react';
|
|
14
8
|
|
|
15
9
|
interface Props {
|
|
@@ -61,44 +55,56 @@ function AcceptInvite() {
|
|
|
61
55
|
|
|
62
56
|
return (
|
|
63
57
|
<AuthCardShell>
|
|
64
|
-
<
|
|
65
|
-
<
|
|
66
|
-
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
58
|
+
<div className="flex items-start gap-3 rounded-xl border border-primary-200 bg-primary-50 p-3 text-sm text-primary-800">
|
|
59
|
+
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
|
60
|
+
<div>
|
|
61
|
+
<p className="font-semibold">You've been invited</p>
|
|
62
|
+
<p className="mt-0.5">Pick a password and you'll be signed in.</p>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
<h1 className="mt-5 mb-1.5 text-[22px] font-bold tracking-tight font-[var(--font-display)] text-foreground">
|
|
66
|
+
Set your password
|
|
67
|
+
</h1>
|
|
68
|
+
<form onSubmit={handleSubmit} className="space-y-3.5">
|
|
69
|
+
<div className="space-y-1.5">
|
|
70
|
+
<Label htmlFor="password" className="text-sm font-medium text-muted-foreground">
|
|
71
|
+
Password
|
|
72
|
+
</Label>
|
|
73
|
+
<Input
|
|
74
|
+
id="password"
|
|
75
|
+
type="password"
|
|
76
|
+
value={password}
|
|
77
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
78
|
+
placeholder="8+ characters"
|
|
79
|
+
required
|
|
80
|
+
autoComplete="new-password"
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
<div className="space-y-1.5">
|
|
84
|
+
<Label htmlFor="confirm" className="text-sm font-medium text-muted-foreground">
|
|
85
|
+
Confirm password
|
|
86
|
+
</Label>
|
|
87
|
+
<Input
|
|
88
|
+
id="confirm"
|
|
89
|
+
type="password"
|
|
90
|
+
value={confirm}
|
|
91
|
+
onChange={(e) => setConfirm(e.target.value)}
|
|
92
|
+
required
|
|
93
|
+
autoComplete="new-password"
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
93
96
|
|
|
94
|
-
|
|
97
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
95
98
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
<Button type="submit" size="lg" className="w-full" disabled={loading || !token}>
|
|
100
|
+
{loading ? 'Activating…' : 'Set password & sign in'}
|
|
101
|
+
</Button>
|
|
102
|
+
</form>
|
|
103
|
+
{token && (
|
|
104
|
+
<p className="mt-4 text-center font-mono text-[11px] text-muted-foreground">
|
|
105
|
+
token={token.slice(0, 16)}…
|
|
106
|
+
</p>
|
|
107
|
+
)}
|
|
102
108
|
</AuthCardShell>
|
|
103
109
|
);
|
|
104
110
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
2
|
+
import { Input } from '@simple-module-py/ui/components/ui/input';
|
|
3
|
+
import { Label } from '@simple-module-py/ui/components/ui/label';
|
|
4
|
+
import { AuthCardShell } from '@simple-module-py/ui/layouts/AuthCardShell';
|
|
5
|
+
import { CheckCircle2 } from 'lucide-react';
|
|
6
|
+
import { useState } from 'react';
|
|
7
|
+
|
|
8
|
+
function ForgotPassword() {
|
|
9
|
+
const [email, setEmail] = useState('');
|
|
10
|
+
const [submitted, setSubmitted] = useState(false);
|
|
11
|
+
const [loading, setLoading] = useState(false);
|
|
12
|
+
|
|
13
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
14
|
+
e.preventDefault();
|
|
15
|
+
setLoading(true);
|
|
16
|
+
fetch('/api/users/auth/forgot-password', {
|
|
17
|
+
method: 'POST',
|
|
18
|
+
headers: { 'Content-Type': 'application/json' },
|
|
19
|
+
body: JSON.stringify({ email }),
|
|
20
|
+
}).finally(() => {
|
|
21
|
+
// Anti-enumeration: always show the same confirmation regardless of
|
|
22
|
+
// whether the email exists (fastapi-users returns 202 either way).
|
|
23
|
+
setLoading(false);
|
|
24
|
+
setSubmitted(true);
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
if (submitted) {
|
|
29
|
+
return (
|
|
30
|
+
<AuthCardShell>
|
|
31
|
+
<div className="flex items-start gap-3 rounded-xl border border-primary-200 bg-primary-50 p-3 text-sm text-primary-800">
|
|
32
|
+
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
|
33
|
+
<div>
|
|
34
|
+
<p className="font-semibold">Check your inbox</p>
|
|
35
|
+
<p className="mt-0.5">
|
|
36
|
+
If <strong>{email}</strong> has an account, a reset link is on its way. The console
|
|
37
|
+
mailer logs it to stdout.
|
|
38
|
+
</p>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
<a
|
|
42
|
+
href="/users/login"
|
|
43
|
+
className="mt-4 block text-center text-sm font-semibold text-primary-700 hover:text-primary-800"
|
|
44
|
+
>
|
|
45
|
+
Back to log in
|
|
46
|
+
</a>
|
|
47
|
+
</AuthCardShell>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<AuthCardShell>
|
|
53
|
+
<h1 className="mb-1.5 text-[22px] font-bold tracking-tight font-[var(--font-display)] text-foreground">
|
|
54
|
+
Forgot password
|
|
55
|
+
</h1>
|
|
56
|
+
<p className="mb-5 text-sm text-muted-foreground">We'll email you a one-time reset link.</p>
|
|
57
|
+
<form onSubmit={handleSubmit} className="space-y-3.5">
|
|
58
|
+
<div className="space-y-1.5">
|
|
59
|
+
<Label htmlFor="email" className="text-sm font-medium text-muted-foreground">
|
|
60
|
+
Email
|
|
61
|
+
</Label>
|
|
62
|
+
<Input
|
|
63
|
+
id="email"
|
|
64
|
+
type="email"
|
|
65
|
+
value={email}
|
|
66
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
67
|
+
placeholder="you@example.com"
|
|
68
|
+
required
|
|
69
|
+
autoComplete="email"
|
|
70
|
+
/>
|
|
71
|
+
</div>
|
|
72
|
+
<Button type="submit" size="lg" className="w-full" disabled={loading}>
|
|
73
|
+
{loading ? 'Sending…' : 'Send reset link'}
|
|
74
|
+
</Button>
|
|
75
|
+
</form>
|
|
76
|
+
<p className="mt-5 text-center text-xs text-muted-foreground">
|
|
77
|
+
Remembered?{' '}
|
|
78
|
+
<a href="/users/login" className="font-semibold text-primary-700 hover:text-primary-800">
|
|
79
|
+
Log in
|
|
80
|
+
</a>
|
|
81
|
+
</p>
|
|
82
|
+
</AuthCardShell>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export default ForgotPassword;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { router, usePage } from '@inertiajs/react';
|
|
2
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
3
|
+
import { Input } from '@simple-module-py/ui/components/ui/input';
|
|
4
|
+
import { Label } from '@simple-module-py/ui/components/ui/label';
|
|
5
|
+
import { AuthCardShell } from '@simple-module-py/ui/layouts/AuthCardShell';
|
|
6
|
+
import { AlertTriangle } from 'lucide-react';
|
|
7
|
+
import { useState } from 'react';
|
|
8
|
+
|
|
9
|
+
interface DevAccount {
|
|
10
|
+
label: string;
|
|
11
|
+
email: string;
|
|
12
|
+
password: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
allow_signup: boolean;
|
|
17
|
+
dev_accounts: DevAccount[];
|
|
18
|
+
login_redirect_url: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function Login() {
|
|
22
|
+
const { allow_signup, dev_accounts, login_redirect_url } = usePage<{ props: Props }>()
|
|
23
|
+
.props as unknown as Props;
|
|
24
|
+
|
|
25
|
+
const [email, setEmail] = useState('');
|
|
26
|
+
const [password, setPassword] = useState('');
|
|
27
|
+
const [error, setError] = useState<string | null>(null);
|
|
28
|
+
const [needsVerification, setNeedsVerification] = useState(false);
|
|
29
|
+
const [loading, setLoading] = useState(false);
|
|
30
|
+
|
|
31
|
+
const nextUrl =
|
|
32
|
+
typeof window !== 'undefined'
|
|
33
|
+
? new URLSearchParams(window.location.search).get('next') || login_redirect_url
|
|
34
|
+
: login_redirect_url;
|
|
35
|
+
|
|
36
|
+
const submitLogin = (username: string, pwd: string) => {
|
|
37
|
+
setError(null);
|
|
38
|
+
setNeedsVerification(false);
|
|
39
|
+
setLoading(true);
|
|
40
|
+
const body = new URLSearchParams({ username, password: pwd });
|
|
41
|
+
fetch('/api/users/auth/login', {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
body,
|
|
44
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
45
|
+
})
|
|
46
|
+
.then(async (res) => {
|
|
47
|
+
if (res.status === 204) {
|
|
48
|
+
router.visit(nextUrl);
|
|
49
|
+
} else if (res.status === 429) {
|
|
50
|
+
setError('Too many attempts. Please try again in a few minutes.');
|
|
51
|
+
} else {
|
|
52
|
+
const data = await res.json().catch(() => ({}));
|
|
53
|
+
const detail = typeof data?.detail === 'string' ? data.detail : '';
|
|
54
|
+
if (detail === 'LOGIN_USER_NOT_VERIFIED') {
|
|
55
|
+
setNeedsVerification(true);
|
|
56
|
+
} else {
|
|
57
|
+
setError('Invalid email or password.');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
.catch(() => setError('An error occurred. Please try again.'))
|
|
62
|
+
.finally(() => setLoading(false));
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
submitLogin(email, password);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const handleResendVerification = () => {
|
|
71
|
+
fetch('/api/users/auth/request-verify-token', {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'Content-Type': 'application/json' },
|
|
74
|
+
body: JSON.stringify({ email }),
|
|
75
|
+
}).then(() => {
|
|
76
|
+
setError('Verification email resent. Please check your inbox.');
|
|
77
|
+
setNeedsVerification(false);
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<AuthCardShell>
|
|
83
|
+
<h1 className="mb-1.5 text-[22px] font-bold tracking-tight font-[var(--font-display)] text-foreground">
|
|
84
|
+
Welcome back
|
|
85
|
+
</h1>
|
|
86
|
+
<p className="mb-5 text-sm text-muted-foreground">Log in to your workspace.</p>
|
|
87
|
+
<form onSubmit={handleSubmit} className="space-y-3.5">
|
|
88
|
+
<div className="space-y-1.5">
|
|
89
|
+
<Label htmlFor="email" className="text-sm font-medium text-muted-foreground">
|
|
90
|
+
Email
|
|
91
|
+
</Label>
|
|
92
|
+
<Input
|
|
93
|
+
id="email"
|
|
94
|
+
type="email"
|
|
95
|
+
value={email}
|
|
96
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
97
|
+
placeholder="you@example.com"
|
|
98
|
+
required
|
|
99
|
+
autoComplete="email"
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
<div className="space-y-1.5">
|
|
103
|
+
<div className="flex items-baseline justify-between">
|
|
104
|
+
<Label htmlFor="password" className="text-sm font-medium text-muted-foreground">
|
|
105
|
+
Password
|
|
106
|
+
</Label>
|
|
107
|
+
<a
|
|
108
|
+
href="/users/forgot-password"
|
|
109
|
+
className="text-xs font-semibold text-primary-700 hover:text-primary-800"
|
|
110
|
+
>
|
|
111
|
+
Forgot?
|
|
112
|
+
</a>
|
|
113
|
+
</div>
|
|
114
|
+
<Input
|
|
115
|
+
id="password"
|
|
116
|
+
type="password"
|
|
117
|
+
value={password}
|
|
118
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
119
|
+
required
|
|
120
|
+
autoComplete="current-password"
|
|
121
|
+
/>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
125
|
+
|
|
126
|
+
{needsVerification && (
|
|
127
|
+
<div className="flex items-start gap-2 rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
|
128
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
|
129
|
+
<div>
|
|
130
|
+
<p className="font-semibold">Verify your email</p>
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
onClick={handleResendVerification}
|
|
134
|
+
className="mt-0.5 underline hover:no-underline"
|
|
135
|
+
>
|
|
136
|
+
Resend verification email
|
|
137
|
+
</button>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
|
|
142
|
+
<Button type="submit" size="lg" className="w-full" disabled={loading}>
|
|
143
|
+
{loading ? 'Signing in…' : 'Log in'}
|
|
144
|
+
</Button>
|
|
145
|
+
</form>
|
|
146
|
+
|
|
147
|
+
{allow_signup && (
|
|
148
|
+
<p className="mt-5 text-center text-xs text-muted-foreground">
|
|
149
|
+
Don't have an account?{' '}
|
|
150
|
+
<a
|
|
151
|
+
href="/users/register"
|
|
152
|
+
className="font-semibold text-primary-700 hover:text-primary-800"
|
|
153
|
+
>
|
|
154
|
+
Sign up
|
|
155
|
+
</a>
|
|
156
|
+
</p>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
{dev_accounts && dev_accounts.length > 0 && (
|
|
160
|
+
<div className="mt-5 border-t border-border pt-4">
|
|
161
|
+
<p className="mb-2 text-center font-mono text-[11px] text-muted-foreground">
|
|
162
|
+
Dev quick-login
|
|
163
|
+
</p>
|
|
164
|
+
<div className="flex flex-wrap justify-center gap-2">
|
|
165
|
+
{dev_accounts.map((acct) => (
|
|
166
|
+
<Button
|
|
167
|
+
key={acct.email}
|
|
168
|
+
type="button"
|
|
169
|
+
variant="outline"
|
|
170
|
+
size="sm"
|
|
171
|
+
disabled={loading}
|
|
172
|
+
onClick={() => {
|
|
173
|
+
setEmail(acct.email);
|
|
174
|
+
setPassword(acct.password);
|
|
175
|
+
submitLogin(acct.email, acct.password);
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
{acct.label}
|
|
179
|
+
</Button>
|
|
180
|
+
))}
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
</AuthCardShell>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export default Login;
|