Implement password change flow

This commit is contained in:
2025-05-30 16:44:55 +02:00
parent 2585eb1fb3
commit f853ca81d0
8 changed files with 353 additions and 33 deletions

View File

@ -0,0 +1,67 @@
{% extends "/dashboard/_base.html" %} {% block content %}
<div class="min-h-screen bg-gray-100 flex items-center justify-center p-4">
<div class="w-full max-w-xl p-6 space-y-8 bg-white rounded-lg shadow sm:p-8 dark:bg-gray-800">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
Change Password
</h2>
{% if errors | list %}
<div class="flex p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400" role="alert">
<svg class="shrink-0 inline w-4 h-4 me-3 mt-[2px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z"/>
</svg>
<span class="sr-only">Danger</span>
<div>
<span class="font-medium">Error changing password:</span>
<ul class="mt-1.5 list-disc list-inside">
{% for error in errors %}
<li> {{ error }}</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
<form class="mt-8 space-y-6" action="/password" method="POST" id="password-change-form">
<input hidden type="text" name="username" value="{{ user.display_name }}" autocomplete="username">
<div>
<label for="current_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Current password</label>
<input type="password" name="current_password" id="current_password" placeholder="••••••••" class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500" required autocomplete="current-password">
</div>
<div hx-target="this" hx-swap="outerHTML">
<div>
<label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">New password</label>
<input
type="password"
name="password"
id="password"
placeholder="••••••••"
class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
required
autocomplete="new-password"
>
</div>
<div>
<label for="confirm_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Confirm New Password</label>
<input
type="password"
name="confirm_password"
id="confirm-password"
placeholder="••••••••"
class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
required
autocomplete="new-password"
hx-post="/password/validate-confirm"
hx-include="[name='password']"
>
</div>
<div class="mt-2">
<button type="submit" class="w-full px-5 py-3 text-base font-medium text-center text-white rounded-lg bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 sm:w-auto dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Change password</button>
</div>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,37 @@
<div hx-target="this" hx-swap="outerHTML">
<div>
<label for="password" class="block mb-2 text-sm font-medium text-red-900 dark:text-white">New password</label>
<input
type="password"
name="password"
id="password"
placeholder="••••••••"
value="{{ password }}"
class="bg-red-50 border border-red-500 text-red-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-red-700 dark:border-red-600 dark:placeholder-red-400 dark:text-white dark:focus:ring-red-500 dark:focus:border-red-500"
required
autocomplete="new-password"
>
</div>
<div>
<label for="confirm_password" class="block mb-2 text-sm font-medium text-red-900 dark:text-white">Confirm New Password</label>
<input
type="password"
name="confirm_password"
id="confirm-password"
placeholder="••••••••"
class="bg-red-50 border border-red-500 text-red-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-red-700 dark:border-red-600 dark:placeholder-red-400 dark:text-white dark:focus:ring-red-500 dark:focus:border-red-500"
required
value="{{ confirm_password }}"
autocomplete="new-password"
hx-post="/password/validate-confirm"
hx-include="[name='password']"
>
</div>
<p class="mt-2 text-sm text-red-600 dark:text-red-500"><span class="font-medium">Oops!</span> Passwords do not match!</p>
<div class="mt-2">
<button type="submit" class="w-full px-5 py-3 text-base font-medium text-center text-white rounded-lg bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 sm:w-auto dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800 cursor-not-allowed" disabled>Change password</button>
</div>
</div>

View File

@ -0,0 +1,12 @@
{% extends "/dashboard/_base.html" %} {% block content %}
<div class="min-h-screen bg-gray-100 flex items-center justify-center p-4">
<div class="text-center xl:max-w-4xl">
<h1 class="mb-3 text-2xl font-bold leading-tight text-gray-900 sm:text-4xl lg:text-5xl dark:text-white">Password Changed</h1>
<p class="mb-5 text-base font-normal text-gray-500 md:text-lg dark:text-gray-400">Your password was changed sucessfully. Next time you log in, use your new password.</p>
<a href="/dashboard" class="text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center mr-3 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
<svg class="mr-2 -ml-1 w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"></path></svg>
Go back to the dashboard
</a>
</div>
</div>
{% endblock content %}

View File

@ -0,0 +1,33 @@
<div hx-target="this" hx-swap="outerHTML">
<div>
<label for="password" class="block mb-2 text-sm font-medium text-green-900 dark:text-white">New password</label>
<input
type="password"
name="password"
id="password"
placeholder="••••••••"
value="{{ password }}"
class="bg-green-50 border border-green-500 text-green-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-green-700 dark:border-green-600 dark:placeholder-green-400 dark:text-white dark:focus:ring-green-500 dark:focus:border-green-500"
required
autocomplete="new-password"
>
</div>
<div>
<label for="confirm_password" class="block mb-2 text-sm font-medium text-green-900 dark:text-white">Confirm New Password</label>
<input
type="password"
name="confirm_password"
id="confirm-password"
placeholder="••••••••"
class="bg-green-50 border border-green-500 text-green-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-green-700 dark:border-green-600 dark:placeholder-green-400 dark:text-white dark:focus:ring-green-500 dark:focus:border-green-500"
required
value="{{ confirm_password }}"
autocomplete="new-password"
hx-post="/password/validate-confirm"
hx-include="[name='password']"
>
</div>
<div class="mt-2">
<button type="submit" class="w-full px-5 py-3 text-base font-medium text-center text-white rounded-lg bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 sm:w-auto dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Change password</button>
</div>
</div>

View File

@ -94,7 +94,7 @@
{% if user.local %} {% if user.local %}
<li> <li>
<a <a
href="#" href="/password"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
role="menuitem" role="menuitem"
>Change Password</a >Change Password</a

View File

@ -3,11 +3,17 @@
# pyright: reportUnusedFunction=false # pyright: reportUnusedFunction=false
import logging import logging
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from pydantic import BaseModel from pydantic import BaseModel
from sshecret_admin.auth import LocalUserInfo from sqlalchemy.ext.asyncio import AsyncSession
from sshecret_admin.auth import LocalUserInfo, authenticate_user_async
from sshecret_admin.auth.authentication import hash_password
from sshecret_admin.frontend.exceptions import RedirectException
from sshecret_admin.services import AdminBackend from sshecret_admin.services import AdminBackend
from starlette.datastructures import URL
from sshecret.backend.models import Operation
from ..dependencies import FrontendDependencies from ..dependencies import FrontendDependencies
@ -25,6 +31,14 @@ class StatsView(BaseModel):
audit_events: int = 0 audit_events: int = 0
class PasswordChangeForm(BaseModel):
"""Password change form data."""
current_password: str
password: str
confirm_password: str
async def get_stats(admin: AdminBackend) -> StatsView: async def get_stats(admin: AdminBackend) -> StatsView:
"""Get stats for the frontpage.""" """Get stats for the frontpage."""
clients = await admin.get_clients() clients = await admin.get_clients()
@ -75,4 +89,102 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
}, },
) )
@app.get("/password")
async def get_change_password(
request: Request,
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
):
"""Render Change password site."""
if not current_user.local:
LOG.debug("User tried to change password, but is not a local user.")
return RedirectException(to=URL("/"))
return templates.TemplateResponse(
request,
"change_password/index.html.j2",
{
"page_title": "Change Password",
"user": current_user,
"errors": [],
},
)
@app.post("/password")
async def change_password(
request: Request,
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
session: Annotated[AsyncSession, Depends(dependencies.get_async_session)],
passwd_form: Annotated[PasswordChangeForm, Form()],
):
"""Change password."""
errors: list[str] = []
user = await authenticate_user_async(
session, current_user.display_name, passwd_form.current_password
)
new_password_matches = passwd_form.password == passwd_form.confirm_password
if not user:
errors.append("Invalid current password entered")
if not new_password_matches:
errors.append("Passwords do not match")
if errors:
return templates.TemplateResponse(
request,
"change_password/index.html.j2",
{
"page_title": "Change Password",
"user": current_user,
"errors": errors,
},
)
assert user is not None
new_password_hash = hash_password(passwd_form.password)
user.hashed_password = new_password_hash
session.add(user)
await session.commit()
origin = "UNKNOWN"
if request.client:
origin = request.client.host
await admin.write_audit_message(
Operation.UPDATE,
"User changed their password",
origin,
username=user.username,
)
return templates.TemplateResponse(
request,
"change_password/success.html.j2",
{
"page_title": "Change Password success",
"user": current_user,
},
)
@app.post("/password/validate-confirm")
async def validate_password_match(
request: Request,
password: Annotated[str, Form()],
confirm_password: Annotated[str, Form()],
):
"""Validate password matches."""
valid = "/change_password/valid_password.html.j2"
invalid = "/change_password/invalid_password.html.j2"
template = valid
if password != confirm_password:
template = invalid
LOG.info("Password matches: %r", (password == confirm_password))
return templates.TemplateResponse(
request,
template,
{
"password": password,
"confirm_password": confirm_password,
},
)
return app return app

View File

@ -25,11 +25,13 @@
--color-orange-800: oklch(47% 0.157 37.304); --color-orange-800: oklch(47% 0.157 37.304);
--color-lime-400: oklch(84.1% 0.238 128.85); --color-lime-400: oklch(84.1% 0.238 128.85);
--color-lime-500: oklch(76.8% 0.233 130.85); --color-lime-500: oklch(76.8% 0.233 130.85);
--color-green-50: oklch(98.2% 0.018 155.826);
--color-green-100: oklch(96.2% 0.044 156.743); --color-green-100: oklch(96.2% 0.044 156.743);
--color-green-200: oklch(92.5% 0.084 155.995); --color-green-200: oklch(92.5% 0.084 155.995);
--color-green-400: oklch(79.2% 0.209 151.711); --color-green-400: oklch(79.2% 0.209 151.711);
--color-green-500: oklch(72.3% 0.219 149.579); --color-green-500: oklch(72.3% 0.219 149.579);
--color-green-600: oklch(62.7% 0.194 149.214); --color-green-600: oklch(62.7% 0.194 149.214);
--color-green-700: oklch(52.7% 0.154 150.069);
--color-green-800: oklch(44.8% 0.119 151.328); --color-green-800: oklch(44.8% 0.119 151.328);
--color-green-900: oklch(39.3% 0.095 152.535); --color-green-900: oklch(39.3% 0.095 152.535);
--color-emerald-500: oklch(69.6% 0.17 162.48); --color-emerald-500: oklch(69.6% 0.17 162.48);
@ -413,9 +415,6 @@
.m-361 { .m-361 {
margin: calc(var(--spacing) * 361); margin: calc(var(--spacing) * 361);
} }
.mx-2 {
margin-inline: calc(var(--spacing) * 2);
}
.mx-3 { .mx-3 {
margin-inline: calc(var(--spacing) * 3); margin-inline: calc(var(--spacing) * 3);
} }
@ -443,12 +442,6 @@
.my-10 { .my-10 {
margin-block: calc(var(--spacing) * 10); margin-block: calc(var(--spacing) * 10);
} }
.my-\[0\.5rem\] {
margin-block: 0.5rem;
}
.my-\[1rem\] {
margin-block: 1rem;
}
.my-auto { .my-auto {
margin-block: auto; margin-block: auto;
} }
@ -500,6 +493,9 @@
.mt-8 { .mt-8 {
margin-top: calc(var(--spacing) * 8); margin-top: calc(var(--spacing) * 8);
} }
.mt-\[2px\] {
margin-top: 2px;
}
.-mr-1 { .-mr-1 {
margin-right: calc(var(--spacing) * -1); margin-right: calc(var(--spacing) * -1);
} }
@ -593,9 +589,6 @@
.box-border { .box-border {
box-sizing: border-box; box-sizing: border-box;
} }
.box-content {
box-sizing: content-box;
}
.block { .block {
display: block; display: block;
} }
@ -779,9 +772,6 @@
.w-full { .w-full {
width: 100%; width: 100%;
} }
.w-max {
width: max-content;
}
.max-w-2xl { .max-w-2xl {
max-width: var(--container-2xl); max-width: var(--container-2xl);
} }
@ -794,9 +784,6 @@
.max-w-lg { .max-w-lg {
max-width: var(--container-lg); max-width: var(--container-lg);
} }
.max-w-max {
max-width: max-content;
}
.max-w-md { .max-w-md {
max-width: var(--container-md); max-width: var(--container-md);
} }
@ -879,12 +866,18 @@
.animate-spin { .animate-spin {
animation: var(--animate-spin); animation: var(--animate-spin);
} }
.cursor-not-allowed {
cursor: not-allowed;
}
.cursor-pointer { .cursor-pointer {
cursor: pointer; cursor: pointer;
} }
.resize { .resize {
resize: both; resize: both;
} }
.list-inside {
list-style-position: inside;
}
.list-disc { .list-disc {
list-style-type: disc; list-style-type: disc;
} }
@ -1218,6 +1211,9 @@
.border-green-100 { .border-green-100 {
border-color: var(--color-green-100); border-color: var(--color-green-100);
} }
.border-green-500 {
border-color: var(--color-green-500);
}
.border-orange-100 { .border-orange-100 {
border-color: var(--color-orange-100); border-color: var(--color-orange-100);
} }
@ -1230,6 +1226,9 @@
.border-red-300 { .border-red-300 {
border-color: var(--color-red-300); border-color: var(--color-red-300);
} }
.border-red-500 {
border-color: var(--color-red-500);
}
.border-red-600 { .border-red-600 {
border-color: var(--color-red-600); border-color: var(--color-red-600);
} }
@ -1296,9 +1295,6 @@
.bg-gray-200 { .bg-gray-200 {
background-color: var(--color-gray-200); background-color: var(--color-gray-200);
} }
.bg-gray-700 {
background-color: var(--color-gray-700);
}
.bg-gray-800 { .bg-gray-800 {
background-color: var(--color-gray-800); background-color: var(--color-gray-800);
} }
@ -1311,6 +1307,9 @@
background-color: color-mix(in oklab, var(--color-gray-900) 50%, transparent); background-color: color-mix(in oklab, var(--color-gray-900) 50%, transparent);
} }
} }
.bg-green-50 {
background-color: var(--color-green-50);
}
.bg-green-100 { .bg-green-100 {
background-color: var(--color-green-100); background-color: var(--color-green-100);
} }
@ -1320,9 +1319,6 @@
.bg-green-400 { .bg-green-400 {
background-color: var(--color-green-400); background-color: var(--color-green-400);
} }
.bg-indigo-200 {
background-color: var(--color-indigo-200);
}
.bg-indigo-600 { .bg-indigo-600 {
background-color: var(--color-indigo-600); background-color: var(--color-indigo-600);
} }
@ -1389,9 +1385,6 @@
.bg-teal-100 { .bg-teal-100 {
background-color: var(--color-teal-100); background-color: var(--color-teal-100);
} }
.bg-teal-700 {
background-color: var(--color-teal-700);
}
.bg-transparent { .bg-transparent {
background-color: transparent; background-color: transparent;
} }
@ -1455,9 +1448,6 @@
.px-6 { .px-6 {
padding-inline: calc(var(--spacing) * 6); padding-inline: calc(var(--spacing) * 6);
} }
.px-\[1\.125rem\] {
padding-inline: 1.125rem;
}
.py-0\.5 { .py-0\.5 {
padding-block: calc(var(--spacing) * 0.5); padding-block: calc(var(--spacing) * 0.5);
} }
@ -1747,6 +1737,9 @@
.text-green-800 { .text-green-800 {
color: var(--color-green-800); color: var(--color-green-800);
} }
.text-green-900 {
color: var(--color-green-900);
}
.text-orange-800 { .text-orange-800 {
color: var(--color-orange-800); color: var(--color-orange-800);
} }
@ -1771,6 +1764,9 @@
.text-red-800 { .text-red-800 {
color: var(--color-red-800); color: var(--color-red-800);
} }
.text-red-900 {
color: var(--color-red-900);
}
.text-rose-500 { .text-rose-500 {
color: var(--color-rose-500); color: var(--color-rose-500);
} }
@ -3260,6 +3256,11 @@
border-color: var(--color-green-500); border-color: var(--color-green-500);
} }
} }
.dark\:border-green-600 {
&:where(.dark, .dark *) {
border-color: var(--color-green-600);
}
}
.dark\:border-orange-300 { .dark\:border-orange-300 {
&:where(.dark, .dark *) { &:where(.dark, .dark *) {
border-color: var(--color-orange-300); border-color: var(--color-orange-300);
@ -3280,6 +3281,11 @@
border-color: var(--color-red-500); border-color: var(--color-red-500);
} }
} }
.dark\:border-red-600 {
&:where(.dark, .dark *) {
border-color: var(--color-red-600);
}
}
.dark\:border-red-800 { .dark\:border-red-800 {
&:where(.dark, .dark *) { &:where(.dark, .dark *) {
border-color: var(--color-red-800); border-color: var(--color-red-800);
@ -3313,6 +3319,11 @@
} }
} }
} }
.dark\:bg-green-700 {
&:where(.dark, .dark *) {
background-color: var(--color-green-700);
}
}
.dark\:bg-orange-400 { .dark\:bg-orange-400 {
&:where(.dark, .dark *) { &:where(.dark, .dark *) {
background-color: var(--color-orange-400); background-color: var(--color-orange-400);
@ -3333,6 +3344,11 @@
background-color: var(--color-primary-900); background-color: var(--color-primary-900);
} }
} }
.dark\:bg-red-700 {
&:where(.dark, .dark *) {
background-color: var(--color-red-700);
}
}
.dark\:bg-red-900 { .dark\:bg-red-900 {
&:where(.dark, .dark *) { &:where(.dark, .dark *) {
background-color: var(--color-red-900); background-color: var(--color-red-900);
@ -3455,6 +3471,20 @@
} }
} }
} }
.dark\:placeholder-green-400 {
&:where(.dark, .dark *) {
&::placeholder {
color: var(--color-green-400);
}
}
}
.dark\:placeholder-red-400 {
&:where(.dark, .dark *) {
&::placeholder {
color: var(--color-red-400);
}
}
}
.dark\:ring-offset-gray-700 { .dark\:ring-offset-gray-700 {
&:where(.dark, .dark *) { &:where(.dark, .dark *) {
--tw-ring-offset-color: var(--color-gray-700); --tw-ring-offset-color: var(--color-gray-700);
@ -3603,6 +3633,13 @@
} }
} }
} }
.dark\:focus\:border-green-500 {
&:where(.dark, .dark *) {
&:focus {
border-color: var(--color-green-500);
}
}
}
.dark\:focus\:border-primary-500 { .dark\:focus\:border-primary-500 {
&:where(.dark, .dark *) { &:where(.dark, .dark *) {
&:focus { &:focus {
@ -3610,6 +3647,13 @@
} }
} }
} }
.dark\:focus\:border-red-500 {
&:where(.dark, .dark *) {
&:focus {
border-color: var(--color-red-500);
}
}
}
.dark\:focus\:bg-gray-700 { .dark\:focus\:bg-gray-700 {
&:where(.dark, .dark *) { &:where(.dark, .dark *) {
&:focus { &:focus {
@ -3645,6 +3689,13 @@
} }
} }
} }
.dark\:focus\:ring-green-500 {
&:where(.dark, .dark *) {
&:focus {
--tw-ring-color: var(--color-green-500);
}
}
}
.dark\:focus\:ring-primary-500 { .dark\:focus\:ring-primary-500 {
&:where(.dark, .dark *) { &:where(.dark, .dark *) {
&:focus { &:focus {
@ -3673,6 +3724,13 @@
} }
} }
} }
.dark\:focus\:ring-red-500 {
&:where(.dark, .dark *) {
&:focus {
--tw-ring-color: var(--color-red-500);
}
}
}
.dark\:focus\:ring-red-800 { .dark\:focus\:ring-red-800 {
&:where(.dark, .dark *) { &:where(.dark, .dark *) {
&:focus { &:focus {

View File

@ -1,6 +1,7 @@
module.exports = { module.exports = {
content: [ content: [
"./src/sshecret_admin/templates/**/*.html", "./src/sshecret_admin/templates/**/*.html",
"./src/sshecret_admin/templates/**/*.html.j2",
"./src/sshecret_admin/static/**/*.js", "./src/sshecret_admin/static/**/*.js",
], ],
safelist: [ safelist: [