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

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 markupsafe import escape 

8 

9 

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. 

23 

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. 

27 

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. 

38 

39 Returns: 

40 HTML string fragment for the profile page body. 

41 """ 

42 

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 ) 

49 

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> 

77 

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 """