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.
Files changed (91) hide show
  1. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/PKG-INFO +6 -6
  2. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/pyproject.toml +6 -6
  3. simple_module_users-0.0.11/users/components/RolesTab.tsx +77 -0
  4. simple_module_users-0.0.11/users/components/UserRow.tsx +80 -0
  5. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/endpoints/views.py +2 -0
  6. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/pages/AcceptInvite.tsx +49 -43
  7. simple_module_users-0.0.11/users/pages/ForgotPassword.tsx +86 -0
  8. simple_module_users-0.0.11/users/pages/Login.tsx +188 -0
  9. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/pages/Profile.tsx +51 -15
  10. simple_module_users-0.0.11/users/pages/Register.tsx +158 -0
  11. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/pages/ResetPassword.tsx +44 -49
  12. simple_module_users-0.0.11/users/pages/Users/Edit.tsx +235 -0
  13. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/pages/Users/Index.tsx +60 -89
  14. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/pages/Users/Invite.tsx +45 -33
  15. simple_module_users-0.0.11/users/pages/Users/components/AccountStatusCard.tsx +102 -0
  16. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/pages/VerifyEmail.tsx +29 -19
  17. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/service.py +14 -0
  18. simple_module_users-0.0.9/users/components/RolesTab.tsx +0 -72
  19. simple_module_users-0.0.9/users/pages/ForgotPassword.tsx +0 -90
  20. simple_module_users-0.0.9/users/pages/Login.tsx +0 -183
  21. simple_module_users-0.0.9/users/pages/Register.tsx +0 -152
  22. simple_module_users-0.0.9/users/pages/Users/Edit.tsx +0 -293
  23. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/.gitignore +0 -0
  24. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/LICENSE +0 -0
  25. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/README.md +0 -0
  26. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/package.json +0 -0
  27. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/.gitkeep +0 -0
  28. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/_middleware_support.py +0 -0
  29. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/conftest.py +0 -0
  30. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_access_token_model.py +0 -0
  31. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_api_admin.py +0 -0
  32. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_api_admin_filters.py +0 -0
  33. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_api_auth.py +0 -0
  34. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_backend.py +0 -0
  35. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_bootstrap.py +0 -0
  36. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_cli.py +0 -0
  37. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_constants.py +0 -0
  38. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_db_adapter.py +0 -0
  39. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_invite_flow.py +0 -0
  40. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_mailer.py +0 -0
  41. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_rate_limit.py +0 -0
  42. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_role_model.py +0 -0
  43. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_service_admin.py +0 -0
  44. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_settings.py +0 -0
  45. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_user_manager.py +0 -0
  46. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_user_model.py +0 -0
  47. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_user_role_model.py +0 -0
  48. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_user_service.py +0 -0
  49. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_users_deps.py +0 -0
  50. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_users_middleware.py +0 -0
  51. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_users_middleware_public_paths.py +0 -0
  52. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_views.py +0 -0
  53. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tests/test_views_admin.py +0 -0
  54. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/tsconfig.json +0 -0
  55. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/__init__.py +0 -0
  56. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/backend.py +0 -0
  57. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/bootstrap.py +0 -0
  58. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/cli.py +0 -0
  59. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/components/IndexFilters.tsx +0 -0
  60. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/constants.py +0 -0
  61. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/contracts/__init__.py +0 -0
  62. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/contracts/events.py +0 -0
  63. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/contracts/schemas.py +0 -0
  64. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/db_adapter.py +0 -0
  65. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/deps.py +0 -0
  66. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/endpoints/__init__.py +0 -0
  67. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/endpoints/api.py +0 -0
  68. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/endpoints/api_admin.py +0 -0
  69. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/exceptions.py +0 -0
  70. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/mailer/__init__.py +0 -0
  71. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/mailer/console.py +0 -0
  72. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/mailer/smtp.py +0 -0
  73. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/mailer/templates/.gitkeep +0 -0
  74. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/mailer/templates/invite.txt +0 -0
  75. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/mailer/templates/reset_password.txt +0 -0
  76. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/mailer/templates/verify_email.txt +0 -0
  77. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/manager.py +0 -0
  78. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/middleware.py +0 -0
  79. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/models/__init__.py +0 -0
  80. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/models/_base.py +0 -0
  81. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/models/access_token.py +0 -0
  82. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/models/role.py +0 -0
  83. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/models/user.py +0 -0
  84. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/models/user_role.py +0 -0
  85. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/module.py +0 -0
  86. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/pages/.gitkeep +0 -0
  87. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/py.typed +0 -0
  88. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/rate_limit.py +0 -0
  89. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/roles_cache.py +0 -0
  90. {simple_module_users-0.0.9 → simple_module_users-0.0.11}/users/settings.py +0 -0
  91. {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.9
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.9
28
- Requires-Dist: simple-module-core==0.0.9
29
- Requires-Dist: simple-module-db==0.0.9
30
- Requires-Dist: simple-module-hosting==0.0.9
31
- Requires-Dist: simple-module-settings==0.0.9
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.9"
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.9",
25
- "simple_module_db==0.0.9",
26
- "simple_module_hosting==0.0.9",
27
- "simple_module_settings==0.0.9",
28
- "simple_module_auth==0.0.9",
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
- <Card className="w-full max-w-sm">
65
- <CardHeader className="space-y-1">
66
- <CardTitle className="text-2xl">Accept invitation</CardTitle>
67
- <CardDescription>Set a password to activate your account</CardDescription>
68
- </CardHeader>
69
- <CardContent>
70
- <form onSubmit={handleSubmit} className="space-y-4">
71
- <div className="space-y-2">
72
- <Label htmlFor="password">Password</Label>
73
- <Input
74
- id="password"
75
- type="password"
76
- value={password}
77
- onChange={(e) => setPassword(e.target.value)}
78
- required
79
- autoComplete="new-password"
80
- />
81
- </div>
82
- <div className="space-y-2">
83
- <Label htmlFor="confirm">Confirm password</Label>
84
- <Input
85
- id="confirm"
86
- type="password"
87
- value={confirm}
88
- onChange={(e) => setConfirm(e.target.value)}
89
- required
90
- autoComplete="new-password"
91
- />
92
- </div>
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
- {error && <p className="text-sm text-destructive">{error}</p>}
97
+ {error && <p className="text-sm text-destructive">{error}</p>}
95
98
 
96
- <Button type="submit" className="w-full" disabled={loading || !token}>
97
- {loading ? 'Activating…' : 'Activate account'}
98
- </Button>
99
- </form>
100
- </CardContent>
101
- </Card>
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;