Coverage for src/lexigram/admin/lib/template/auth.py: 16%

70 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-24 23:39 +0800

1"""Template utilities for lexigram-admin. 

2 

3Provides simple template rendering functions for standalone pages 

4like login, error, etc. Uses StandaloneLayout for consistent styling. 

5""" 

6 

7from typing import Any 

8 

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) 

27 

28 

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. 

40 

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. 

51 

52 Returns: 

53 HTML string for login page. 

54 """ 

55 flash_messages = _flash_messages(error, notice) 

56 

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 ) 

65 

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 ) 

88 

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 ) 

97 

98 

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. 

106 

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

113 

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 ) 

125 

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 ) 

140 

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 ) 

152 

153 

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. 

163 

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. 

171 

172 Returns: 

173 HTML string for the confirm page. 

174 """ 

175 flash_messages = _flash_messages(error) 

176 

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 ) 

199 

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 ) 

211 

212 

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. 

222 

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. 

228 

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

236 

237 Returns: 

238 HTML string for the challenge page. 

239 """ 

240 flash_messages = _flash_messages(error, resend_notice) 

241 

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 

269 

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) 

281 

282 return _standalone_card( 

283 "Two-Factor Authentication", 

284 "Verification Code", 

285 guidance, 

286 children, 

287 flash_messages=flash_messages, 

288 ) 

289 

290 

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. 

300 

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``. 

304 

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. 

312 

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 ) 

322 

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 ) 

335 

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 ) 

348 

349 

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. 

356 

357 Shown after an admin clicks a valid verification link. Serves as the 

358 post-verification entry point back into the login flow. 

359 

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. 

364 

365 Returns: 

366 HTML string for the confirmation page. 

367 """ 

368 flash_messages = _flash_messages(error) 

369 

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" 

380 

381 action = el( 

382 "div", 

383 _primary_link(action_label, action_url), 

384 class_="text-center", 

385 ) 

386 

387 return _standalone_card( 

388 "Email Verified", 

389 heading, 

390 copy, 

391 [action], 

392 site_name=site_name, 

393 flash_messages=flash_messages, 

394 ) 

395 

396 

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. 

405 

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. 

411 

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. 

419 

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 

424 

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" 

462 

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) 

475 

476 

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. 

490 

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. 

502 

503 Returns: 

504 HTML string for the registration page. 

505 """ 

506 flash_messages = _flash_messages(error, notice) 

507 

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 ) 

545 

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 )