Coverage for src/lexigram/admin/lib/template/auth.py: 99%
70 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 15:04 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 15:04 +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 typing import Any
9from lexigram.admin.lib.template.layout import (
10 _auth_footer,
11 _auth_form,
12 _code_input,
13 _email_badge,
14 _flash_messages,
15 _primary_link,
16 _standalone_card,
17)
18from lexigram.ui import (
19 EmailInput,
20 Link,
21 PasswordInput,
22 TextInput,
23 el,
24 raw,
25 render_to_string,
26)
29def render_login_page(
30 next_url: str = "/admin/",
31 error: str = "",
32 site_name: str = "Lexigram Admin",
33 csrf_token: str = "",
34 notice: str = "",
35 registration_enabled: bool = False,
36 email_err: str = "",
37 password_err: str = "",
38) -> str:
39 """Render a standalone login page.
41 Args:
42 next_url: URL to redirect to after login.
43 error: Error message to display.
44 site_name: Site name for branding.
45 csrf_token: CSRF token to embed as a hidden form field.
46 notice: Optional success notice to display (e.g. after a password reset).
47 registration_enabled: When ``True`` a "Create account" link to
48 ``/admin/register`` is shown next to the password-reset link.
49 email_err: Optional per-field error under the email input.
50 password_err: Optional per-field error under the password input.
52 Returns:
53 HTML string for login page.
54 """
55 flash_messages = _flash_messages(error, notice)
57 footer_links: list[Any] = [
58 Link("Forgot password?", "/admin/password-reset", variant="primary"),
59 ]
60 if registration_enabled:
61 footer_links.append(el("span", "|", class_="mx-2 text-muted-foreground"))
62 footer_links.append(
63 Link("Create account", "/admin/register", variant="primary")
64 )
66 form = _auth_form(
67 "/admin/login",
68 csrf_token,
69 [
70 EmailInput(
71 name="email",
72 label="Email",
73 placeholder="your@email.com",
74 required=True,
75 error=email_err or None,
76 ),
77 PasswordInput(
78 name="password",
79 label="Password",
80 placeholder="Password",
81 required=True,
82 error=password_err or None,
83 ),
84 ],
85 "Sign In",
86 hidden=[("next", next_url)],
87 )
89 return _standalone_card(
90 "Login",
91 "Sign In",
92 "Please sign in to continue",
93 [form, _auth_footer(*footer_links)],
94 site_name=site_name,
95 flash_messages=flash_messages,
96 )
99def render_password_reset_request_page(
100 site_name: str = "Lexigram Admin",
101 csrf_token: str = "",
102 error: str = "",
103 sent: bool = False,
104) -> str:
105 """Render a standalone password reset request page.
107 Args:
108 site_name: Site name for branding.
109 csrf_token: CSRF token to embed as a hidden form field.
110 error: Error message to display.
111 sent: When True, shows the generic "check your email" notice
112 (anti-enumeration; identical for known and unknown emails).
114 Returns:
115 HTML string for the request page.
116 """
117 flash_messages = _flash_messages(error)
118 if sent:
119 flash_messages.append(
120 (
121 "success",
122 "If an account exists for that email, a password reset link has been sent.",
123 )
124 )
126 form = _auth_form(
127 "/admin/password-reset",
128 csrf_token,
129 [
130 EmailInput(
131 name="email",
132 label="Email",
133 placeholder="your@email.com",
134 required=True,
135 autofocus=True,
136 ),
137 ],
138 "Send Reset Link",
139 )
141 return _standalone_card(
142 "Password Reset",
143 "Forgot Password?",
144 "Enter your email and we'll send you a reset link",
145 [
146 form,
147 _auth_footer(Link("Back to sign in", "/admin/login", variant="primary")),
148 ],
149 site_name=site_name,
150 flash_messages=flash_messages,
151 )
154def render_password_reset_confirm_page(
155 token: str,
156 site_name: str = "Lexigram Admin",
157 csrf_token: str = "",
158 error: str = "",
159 password_err: str = "",
160 confirmation_err: str = "",
161) -> str:
162 """Render a standalone password reset confirm page.
164 Args:
165 token: Raw reset token from the emailed link.
166 site_name: Site name for branding.
167 csrf_token: CSRF token to embed as a hidden form field.
168 error: Error message to display.
169 password_err: Optional per-field error under the password input.
170 confirmation_err: Optional per-field error under the confirm input.
172 Returns:
173 HTML string for the confirm page.
174 """
175 flash_messages = _flash_messages(error)
177 form = _auth_form(
178 f"/admin/password-reset/{token}",
179 csrf_token,
180 [
181 PasswordInput(
182 name="password",
183 label="New Password",
184 placeholder="New password",
185 required=True,
186 autofocus=True,
187 error=password_err or None,
188 ),
189 PasswordInput(
190 name="password_confirmation",
191 label="Confirm Password",
192 placeholder="Repeat password",
193 required=True,
194 error=confirmation_err or None,
195 ),
196 ],
197 "Reset Password",
198 )
200 return _standalone_card(
201 "Set New Password",
202 "Set New Password",
203 "Choose a strong new password",
204 [
205 form,
206 _auth_footer(Link("Back to sign in", "/admin/login", variant="primary")),
207 ],
208 site_name=site_name,
209 flash_messages=flash_messages,
210 )
213def render_mfa_challenge_page(
214 email: str = "",
215 error: str = "",
216 csrf_token: str = "",
217 next_url: str = "/admin/",
218 factor: str = "totp",
219 resend_notice: str = "",
220) -> str:
221 """Render a standalone second-factor challenge page.
223 Shown after password authentication when a challenge is required;
224 completes the login by posting a verification code to
225 ``/admin/login/2fa``. The ``factor`` argument switches the guidance
226 copy between the TOTP authenticator and email OTP, and adds a resend
227 form (``/admin/login/2fa/resend``) for the email factor.
229 Args:
230 email: Account email (displayed in the guidance copy).
231 error: Error message to display.
232 csrf_token: CSRF token to embed as a hidden form field.
233 next_url: Destination to redirect to after successful verification.
234 factor: Second factor in use — ``"totp"`` (default) or ``"email"``.
235 resend_notice: Success notice to display (e.g. after a resend).
237 Returns:
238 HTML string for the challenge page.
239 """
240 flash_messages = _flash_messages(error, resend_notice)
242 if factor == "email":
243 guidance = (
244 f"Enter the 6-digit code we emailed{(' to ' + email) if email else ''}"
245 )
246 resend_block = el(
247 "div",
248 el(
249 "p",
250 "Didn't receive a code?",
251 class_="text-sm text-muted-foreground mb-2",
252 ),
253 _auth_form(
254 "/admin/login/2fa/resend",
255 csrf_token,
256 [],
257 "Resend code",
258 hidden=[("email", email), ("next", next_url)],
259 submit_variant="link",
260 ),
261 class_="mt-4 pt-4 border-t border-border text-center",
262 )
263 else:
264 guidance = (
265 f"Enter the 6-digit code from your authenticator app"
266 f"{(' for ' + email) if email else ''}"
267 )
268 resend_block = None
270 children: list[Any] = [
271 _auth_form(
272 "/admin/login/2fa",
273 csrf_token,
274 [_code_input("Code")],
275 "Verify & Sign In",
276 hidden=[("next", next_url)],
277 ),
278 ]
279 if resend_block is not None:
280 children.append(resend_block)
282 return _standalone_card(
283 "Two-Factor Authentication",
284 "Verification Code",
285 guidance,
286 children,
287 flash_messages=flash_messages,
288 )
291def render_verify_email_page(
292 site_name: str = "Lexigram Admin",
293 email: str = "",
294 error: str = "",
295 notice: str = "",
296 csrf_token: str = "",
297 next_url: str = "/admin/",
298) -> str:
299 """Render a standalone email verification landing page.
301 Shown after a login attempt when the account email is unverified and
302 enforcement is on. Lets the user request a fresh verification link by
303 posting to ``/admin/verify-email/resend``.
305 Args:
306 site_name: Site name for branding.
307 email: Account email (displayed in the guidance copy).
308 error: Error message to display.
309 notice: Success notice to display (e.g. after a resend).
310 csrf_token: CSRF token to embed as a hidden form field.
311 next_url: Destination to redirect to after login.
313 Returns:
314 HTML string for the verification landing page.
315 """
316 flash_messages = _flash_messages(error, notice)
317 copy = (
318 "A verification link was sent"
319 f"{(' to ' + email) if email else ''}. "
320 "Click it to activate your account."
321 )
323 hint = el(
324 "p",
325 "If you don't see the email, check your spam folder or request a new link.",
326 class_="text-sm text-muted-foreground mb-4",
327 )
328 form = _auth_form(
329 "/admin/verify-email/resend",
330 csrf_token,
331 [],
332 "Resend Verification Link",
333 hidden=[("email", email), ("next", next_url)],
334 )
336 return _standalone_card(
337 "Verify Your Email",
338 "Verify Your Email",
339 copy,
340 [
341 hint,
342 form,
343 _auth_footer(Link("Back to login", "/admin/login", variant="primary")),
344 ],
345 site_name=site_name,
346 flash_messages=flash_messages,
347 )
350def render_email_verified_page(
351 site_name: str = "Lexigram Admin",
352 error: str = "",
353 next_url: str = "/admin/",
354) -> str:
355 """Render a standalone "email verified" confirmation page.
357 Shown after an admin clicks a valid verification link. Serves as the
358 post-verification entry point back into the login flow.
360 Args:
361 site_name: Site name for branding.
362 error: Error message to display (e.g. expired or invalid token).
363 next_url: Destination to redirect to after login.
365 Returns:
366 HTML string for the confirmation page.
367 """
368 flash_messages = _flash_messages(error)
370 if error:
371 heading = "Verification Failed"
372 copy = error
373 action_url = "/admin/login"
374 action_label = "Back to login"
375 else:
376 heading = "Email Verified"
377 copy = "Your email address has been verified — you can now sign in."
378 action_url = f"/admin/login?next={next_url}"
379 action_label = "Sign in"
381 action = el(
382 "div",
383 _primary_link(action_label, action_url),
384 class_="text-center",
385 )
387 return _standalone_card(
388 "Email Verified",
389 heading,
390 copy,
391 [action],
392 site_name=site_name,
393 flash_messages=flash_messages,
394 )
397def render_mfa_setup_page(
398 enabled: bool,
399 qr_svg: str = "",
400 secret: str = "",
401 csrf_token: str = "",
402 email_verified: bool | None = None,
403) -> str:
404 """Render the profile 2FA setup fragment for the admin shell.
406 When 2FA is disabled the page shows a QR code and secret plus a
407 confirm form posting to ``/admin/profile/mfa/setup``. When enabled it
408 shows a disable form posting to ``/admin/profile/mfa/disable``. Flash
409 messages are supplied by the shell (via the request context), not
410 embedded here.
412 Args:
413 enabled: True when 2FA is already active.
414 qr_svg: Inline SVG QR code (trusted output of the MFA service).
415 secret: Base32 TOTP secret to store in the authenticator.
416 csrf_token: CSRF token to embed as a hidden form field.
417 email_verified: Optional email verification status badge; when
418 omitted no status is rendered.
420 Returns:
421 HTML fragment for the setup section inside the admin shell.
422 """
423 badge = _email_badge(email_verified) if email_verified is not None else None
425 if enabled:
426 children: list[Any] = [
427 _auth_form(
428 "/admin/profile/mfa/disable",
429 csrf_token,
430 [_code_input("Current Code")],
431 "Disable 2FA",
432 submit_variant="destructive",
433 ),
434 ]
435 children = ([badge] if badge is not None else []) + children
436 heading = "Two-Factor Authentication"
437 copy = "Enabled — your account is protected by an authenticator app"
438 else:
439 qr = el("div", raw(qr_svg), class_="flex justify-center mb-4")
440 secret_block = el(
441 "div",
442 el(
443 "p",
444 "If you cannot scan, enter this secret manually:",
445 class_="text-sm text-muted-foreground mb-1 break-all",
446 ),
447 el(
448 "p",
449 secret,
450 class_="text-center font-mono text-sm bg-muted rounded-md p-2 mb-4 break-all",
451 ),
452 )
453 form = _auth_form(
454 "/admin/profile/mfa/setup",
455 csrf_token,
456 [_code_input("Verification Code")],
457 "Enable 2FA",
458 )
459 children = ([badge] if badge is not None else []) + [qr, secret_block, form]
460 heading = "Enable 2FA"
461 copy = "Scan the QR code with your authenticator app"
463 card = el(
464 "div",
465 el(
466 "div",
467 el("h1", heading, class_="text-2xl font-bold text-foreground mb-2"),
468 el("p", copy, class_="text-sm text-muted-foreground"),
469 class_="text-center mb-6",
470 ),
471 *children,
472 class_="w-full max-w-md mx-auto bg-card border border-border rounded-lg shadow-lg p-8",
473 )
474 return render_to_string(card)
477def render_register_page(
478 site_name: str = "Lexigram Admin",
479 csrf_token: str = "",
480 error: str = "",
481 notice: str = "",
482 name: str = "",
483 email: str = "",
484 name_err: str = "",
485 email_err: str = "",
486 password_err: str = "",
487 confirmation_err: str = "",
488) -> str:
489 """Render a standalone self-service registration page.
491 Args:
492 site_name: Site name for branding.
493 csrf_token: CSRF token to embed as a hidden form field.
494 error: Error message to display.
495 notice: Optional success notice to display.
496 name: Previously submitted display name (re-shown on error).
497 email: Previously submitted email (re-shown on error).
498 name_err: Optional per-field error under the name input.
499 email_err: Optional per-field error under the email input.
500 password_err: Optional per-field error under the password input.
501 confirmation_err: Optional per-field error under the confirm input.
503 Returns:
504 HTML string for the registration page.
505 """
506 flash_messages = _flash_messages(error, notice)
508 form = _auth_form(
509 "/admin/register",
510 csrf_token,
511 [
512 TextInput(
513 name="name",
514 label="Name",
515 value=name,
516 placeholder="Your name",
517 required=True,
518 error=name_err or None,
519 ),
520 EmailInput(
521 name="email",
522 label="Email",
523 value=email,
524 placeholder="your@email.com",
525 required=True,
526 error=email_err or None,
527 ),
528 PasswordInput(
529 name="password",
530 label="Password",
531 placeholder="Password",
532 required=True,
533 error=password_err or None,
534 ),
535 PasswordInput(
536 name="password_confirmation",
537 label="Confirm Password",
538 placeholder="Confirm password",
539 required=True,
540 error=confirmation_err or None,
541 ),
542 ],
543 "Create Account",
544 )
546 return _standalone_card(
547 "Register",
548 "Create Account",
549 "Register to access the admin panel",
550 [
551 form,
552 _auth_footer(
553 "Already have an account? ",
554 Link("Sign in", "/admin/login", variant="primary"),
555 ),
556 ],
557 site_name=site_name,
558 flash_messages=flash_messages,
559 )