Coverage for src/lexigram/admin/lib/template/profile.py: 17%
12 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:39 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:39 +0800
1"""Template utilities for lexigram-admin.
3Provides simple template rendering functions for standalone pages
4like login, error, etc. Uses StandaloneLayout for consistent styling.
5"""
7from markupsafe import escape
10def render_profile_page(
11 *,
12 name: str,
13 email: str,
14 roles: list[str],
15 user_id: str,
16 mfa_enabled: bool,
17 csrf_token: str,
18 current_password_err: str = "",
19 new_password_err: str = "",
20 confirmation_err: str = "",
21) -> str:
22 """Render the user profile page content for the admin shell.
24 Displays account details, two-factor authentication status, and a
25 change-password form. MFA management (setup/disable) lives on the
26 dedicated ``/admin/profile/mfa`` screen; this page links to it.
28 Args:
29 name: Display name of the current user.
30 email: Email of the current user.
31 roles: Roles assigned to the current user.
32 user_id: Identifier of the current user.
33 mfa_enabled: Whether two-factor authentication is active.
34 csrf_token: CSRF token for the change-password form.
35 current_password_err: Optional per-field error under the current-password input.
36 new_password_err: Optional per-field error under the new-password input.
37 confirmation_err: Optional per-field error under the confirm input.
39 Returns:
40 HTML string fragment for the profile page body.
41 """
43 def field_error(message: str) -> str:
44 return (
45 f'<p class="mt-1 text-xs text-destructive" role="alert">{escape(message)}</p>'
46 if message
47 else ""
48 )
50 error_ring = "border-destructive focus:ring-destructive"
51 current_classes = f"mt-1 flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring {error_ring if current_password_err else ''}"
52 new_classes = f"mt-1 flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring {error_ring if new_password_err else ''}"
53 confirm_classes = f"mt-1 flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring {error_ring if confirmation_err else ''}"
54 initials = "".join(part[0] for part in name.split()[:2]).upper() or "?"
55 role_chips = "".join(
56 f'<span class="inline-flex items-center rounded-full bg-accent px-2.5 py-0.5 text-xs font-medium text-accent-foreground">{escape(role)}</span>'
57 for role in roles
58 )
59 mfa_badge = (
60 '<span class="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-700">Enabled</span>'
61 if mfa_enabled
62 else '<span class="inline-flex items-center rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium text-muted-foreground">Disabled</span>'
63 )
64 return f"""
65 <div class="max-w-3xl mx-auto space-y-6">
66 <div class="rounded-lg border border-border bg-card shadow-sm">
67 <div class="flex items-center gap-4 p-6">
68 <div class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-primary text-lg font-bold text-primary-foreground">{escape(initials)}</div>
69 <div class="min-w-0">
70 <h2 class="text-xl font-bold text-foreground">{escape(name)}</h2>
71 <p class="text-sm text-muted-foreground">{escape(email)}</p>
72 <div class="mt-2 flex flex-wrap gap-1.5">{role_chips or '<span class="text-xs text-muted-foreground">No roles</span>'}</div>
73 </div>
74 </div>
75 <div class="border-t border-border px-6 py-3 text-xs text-muted-foreground">User ID: <code>{escape(user_id)}</code></div>
76 </div>
78 <div class="rounded-lg border border-border bg-card shadow-sm">
79 <div class="border-b border-border px-6 py-4">
80 <h3 class="text-lg font-semibold text-foreground">Security</h3>
81 </div>
82 <div class="px-6 py-4 space-y-6">
83 <div class="flex items-center justify-between gap-4">
84 <div>
85 <p class="font-medium text-foreground">Two-factor authentication</p>
86 <p class="text-sm text-muted-foreground">Add an extra layer of security to your account.</p>
87 </div>
88 <div class="flex items-center gap-3">
89 {mfa_badge}
90 <a href="/admin/profile/mfa"
91 class="inline-flex items-center rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium text-foreground shadow-sm hover:bg-accent">Manage</a>
92 </div>
93 </div>
94 <hr class="border-border" />
95 <form method="post" action="/admin/profile/password" class="space-y-4">
96 <input type="hidden" name="csrf_token" value="{escape(csrf_token)}" />
97 <div>
98 <label for="current_password" class="block text-sm font-medium text-foreground">Current password</label>
99 <input type="password" name="current_password" id="current_password" required
100 class="{current_classes}" />
101 {field_error(current_password_err)}
102 </div>
103 <div class="grid gap-4 sm:grid-cols-2">
104 <div>
105 <label for="new_password" class="block text-sm font-medium text-foreground">New password</label>
106 <input type="password" name="new_password" id="new_password" required minlength="8"
107 class="{new_classes}" />
108 {field_error(new_password_err)}
109 </div>
110 <div>
111 <label for="new_password_confirmation" class="block text-sm font-medium text-foreground">Confirm new password</label>
112 <input type="password" name="new_password_confirmation" id="new_password_confirmation" required minlength="8"
113 class="{confirm_classes}" />
114 {field_error(confirmation_err)}
115 </div>
116 </div>
117 <button type="submit"
118 class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90">Change password</button>
119 </form>
120 </div>
121 </div>
122 </div>
123 """