fix(merge): resolve conflicts with main v0.92.0 keeping path-param regression tests

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-08 11:19:22 +00:00
parent c31b72810e
commit 2d754d52ef
22 changed files with 2256 additions and 122 deletions
+253 -2
View File
@@ -508,13 +508,38 @@
</td>
<td class="px-4 py-3 text-sm text-gray-500 whitespace-nowrap" x-text="lu.created_at ? formatDate(lu.created_at) : '—'"></td>
<td class="px-4 py-3 text-sm text-right">
<button
type="button"
@click="openEditLocalUserModal(lu)"
class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded border border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-blue-500 mr-1"
:aria-label="`Edit account for ${lu.username}`"
>
<i class="fas fa-edit mr-1" aria-hidden="true"></i> Edit
</button>
<button
type="button"
@click="openSetPasswordModal(lu)"
class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded border border-yellow-300 text-yellow-700 bg-white hover:bg-yellow-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-yellow-400 mr-1"
:aria-label="`Set password for ${lu.username}`"
>
<i class="fas fa-key mr-1" aria-hidden="true"></i> Password
</button>
<button
type="button"
@click="sendPasswordReset(lu)"
class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded border border-indigo-300 text-indigo-600 bg-white hover:bg-indigo-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-400 mr-1"
:aria-label="`Send password reset email to ${lu.username}`"
title="Send password reset email"
>
<i class="fas fa-envelope mr-1" aria-hidden="true"></i> Reset
</button>
<button
type="button"
@click="confirmDeleteLocalUser(lu)"
class="text-red-600 hover:text-red-800 focus:outline-none"
class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded border border-red-300 text-red-600 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-red-400"
:aria-label="`Delete account for ${lu.username}`"
>
<i class="fas fa-trash" aria-hidden="true"></i>
<i class="fas fa-trash mr-1" aria-hidden="true"></i> Delete
</button>
</td>
</tr>
@@ -595,6 +620,116 @@
</div>
</div>
<!-- ── Edit local user modal ────────────────────────────────────────────── -->
<div
x-show="editLocalUserModal.open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 px-4"
role="dialog"
aria-modal="true"
aria-labelledby="edit-local-user-title"
>
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg" @click.outside="editLocalUserModal.open = false">
<div class="px-6 py-4 border-b flex items-center justify-between">
<h2 id="edit-local-user-title" class="text-lg font-semibold text-gray-900">Edit Local Account</h2>
<button type="button" @click="editLocalUserModal.open = false" aria-label="Close" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<form @submit.prevent="submitEditLocalUser" class="px-6 py-5 space-y-4">
<div>
<label for="elu-email" class="block text-sm font-medium text-gray-700">Email <span aria-hidden="true" class="text-red-500">*</span></label>
<input type="email" id="elu-email" x-model="editLocalUserModal.form.email" required autocomplete="off"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 text-sm"
style="min-height:40px;" aria-required="true">
</div>
<div>
<label for="elu-display-name" class="block text-sm font-medium text-gray-700">Display Name <span class="text-gray-400">(optional)</span></label>
<input type="text" id="elu-display-name" x-model="editLocalUserModal.form.display_name" autocomplete="off" maxlength="255"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 text-sm"
style="min-height:40px;">
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="elu-is-admin" x-model="editLocalUserModal.form.is_admin"
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<label for="elu-is-admin" class="text-sm text-gray-700">Admin privileges</label>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="elu-is-active" x-model="editLocalUserModal.form.is_active"
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<label for="elu-is-active" class="text-sm text-gray-700">Account active</label>
</div>
<div x-show="editLocalUserModal.error" x-cloak
class="bg-red-50 border-l-4 border-red-500 text-red-700 p-3 rounded text-sm"
role="alert" aria-live="assertive" x-text="editLocalUserModal.error">
</div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" @click="editLocalUserModal.open = false"
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50">
Cancel
</button>
<button type="submit" :disabled="editLocalUserModal.saving"
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-blue-500 disabled:opacity-50">
<span x-show="!editLocalUserModal.saving">Save Changes</span>
<span x-show="editLocalUserModal.saving" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Saving…</span>
</button>
</div>
</form>
</div>
</div>
<!-- ── Set password modal ────────────────────────────────────────────────── -->
<div
x-show="setPasswordModal.open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 px-4"
role="dialog"
aria-modal="true"
aria-labelledby="set-password-title"
>
<div class="bg-white rounded-lg shadow-xl w-full max-w-md" @click.outside="setPasswordModal.open = false">
<div class="px-6 py-4 border-b flex items-center justify-between">
<h2 id="set-password-title" class="text-lg font-semibold text-gray-900">Set Temporary Password</h2>
<button type="button" @click="setPasswordModal.open = false" aria-label="Close" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<form @submit.prevent="submitSetPassword" class="px-6 py-5 space-y-4">
<p class="text-sm text-gray-600">
Set a new password directly for <strong class="font-mono" x-text="setPasswordModal.username"></strong>.
The user should change this password after logging in.
</p>
<div>
<label for="sp-password" class="block text-sm font-medium text-gray-700">New Password <span aria-hidden="true" class="text-red-500">*</span></label>
<input type="password" id="sp-password" x-model="setPasswordModal.password" required autocomplete="new-password"
minlength="8" maxlength="128"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-yellow-500 focus:ring focus:ring-yellow-500 focus:ring-opacity-50 text-sm"
style="min-height:40px;" aria-required="true" aria-describedby="sp-password-hint">
<p id="sp-password-hint" class="mt-1 text-xs text-gray-500">Minimum 8 characters.</p>
</div>
<div x-show="setPasswordModal.error" x-cloak
class="bg-red-50 border-l-4 border-red-500 text-red-700 p-3 rounded text-sm"
role="alert" aria-live="assertive" x-text="setPasswordModal.error">
</div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" @click="setPasswordModal.open = false"
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50">
Cancel
</button>
<button type="submit" :disabled="setPasswordModal.saving"
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-yellow-600 hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-yellow-500 disabled:opacity-50">
<span x-show="!setPasswordModal.saving">Set Password</span>
<span x-show="setPasswordModal.saving" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Setting…</span>
</button>
</div>
</form>
</div>
</div>
<!-- ── Delete local user confirmation modal ───────────────────────────────── -->
<div
x-show="deleteLocalUserModal.open"
@@ -724,6 +859,21 @@ function adminUsersApp() {
error: '',
form: { email: '', username: '', display_name: '', password: '', is_admin: false },
},
editLocalUserModal: {
open: false,
id: null,
saving: false,
error: '',
form: { email: '', display_name: '', is_admin: false, is_active: true },
},
setPasswordModal: {
open: false,
id: null,
username: '',
saving: false,
error: '',
password: '',
},
deleteLocalUserModal: {
open: false,
id: null,
@@ -947,6 +1097,107 @@ function adminUsersApp() {
this.deleteLocalUserModal.open = true;
},
openEditLocalUserModal(lu) {
this.editLocalUserModal.id = lu.id;
this.editLocalUserModal.form = {
email: lu.email,
display_name: lu.display_name || '',
is_admin: !!lu.is_admin,
is_active: !!lu.is_active,
};
this.editLocalUserModal.error = '';
this.editLocalUserModal.saving = false;
this.editLocalUserModal.open = true;
},
async submitEditLocalUser() {
this.editLocalUserModal.error = '';
this.editLocalUserModal.saving = true;
try {
const resp = await fetch(`/api/admin/users/local/${this.editLocalUserModal.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
},
body: JSON.stringify({
email: this.editLocalUserModal.form.email || null,
display_name: this.editLocalUserModal.form.display_name,
is_admin: this.editLocalUserModal.form.is_admin,
is_active: this.editLocalUserModal.form.is_active,
}),
});
if (resp.ok) {
this.editLocalUserModal.open = false;
this.showAlert('success', 'Updated', `Account has been updated.`);
await this.fetchLocalUsers();
} else {
const err = await resp.json().catch(() => ({}));
this.editLocalUserModal.error = err.detail || 'Failed to update account.';
}
} catch (e) {
this.editLocalUserModal.error = 'Network error: ' + e.message;
} finally {
this.editLocalUserModal.saving = false;
}
},
openSetPasswordModal(lu) {
this.setPasswordModal.id = lu.id;
this.setPasswordModal.username = lu.username;
this.setPasswordModal.password = '';
this.setPasswordModal.error = '';
this.setPasswordModal.saving = false;
this.setPasswordModal.open = true;
},
async submitSetPassword() {
this.setPasswordModal.error = '';
this.setPasswordModal.saving = true;
try {
const resp = await fetch(`/api/admin/users/local/${this.setPasswordModal.id}/set-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
},
body: JSON.stringify({ password: this.setPasswordModal.password }),
});
if (resp.ok) {
this.setPasswordModal.open = false;
this.showAlert('success', 'Password set', `Password for "${this.setPasswordModal.username}" has been updated.`);
} else {
const err = await resp.json().catch(() => ({}));
this.setPasswordModal.error = err.detail || 'Failed to set password.';
}
} catch (e) {
this.setPasswordModal.error = 'Network error: ' + e.message;
} finally {
this.setPasswordModal.saving = false;
}
},
async sendPasswordReset(lu) {
try {
const resp = await fetch(`/api/admin/users/local/${lu.id}/send-password-reset`, {
method: 'POST',
headers: {
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
},
});
const data = await resp.json().catch(() => ({}));
if (resp.ok && data.sent) {
this.showAlert('success', 'Email sent', `Password reset email sent to "${lu.email}".`);
} else if (resp.ok && !data.sent) {
this.showAlert('error', 'Email not sent', data.reason || 'SMTP is not configured.');
} else {
this.showAlert('error', 'Failed', data.detail || resp.statusText);
}
} catch (e) {
this.showAlert('error', 'Network error', e.message);
}
},
async executeDeleteLocalUser() {
this.deleteLocalUserModal.deleting = true;
try {
+118
View File
@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Forgot Password</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center py-8">
<main class="bg-white rounded-lg shadow-lg p-8 max-w-md w-full" role="main">
<div class="flex justify-center mb-6">
<img src="/static/images/logo_writing.svg" alt="DocuElevate Logo" class="h-16">
</div>
<h1 class="text-2xl font-bold text-center text-gray-800 mb-2">Forgot your password?</h1>
<p class="text-center text-gray-500 text-sm mb-6">
Enter your email address and we'll send you a link to reset your password.
</p>
<div
x-data="{
email: '',
loading: false,
error: '',
success: false,
async submit() {
this.error = '';
this.loading = true;
try {
const resp = await fetch('/api/auth/request-password-reset', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': '{{ csrf_token }}'
},
body: JSON.stringify({ email: this.email })
});
if (resp.ok) {
this.success = true;
} else {
const data = await resp.json().catch(() => ({}));
this.error = data.detail || 'Something went wrong. Please try again.';
}
} catch(e) {
this.error = 'Network error. Please try again.';
} finally {
this.loading = false;
}
}
}"
>
<div x-show="success" x-cloak class="text-center py-4">
<div class="flex justify-center mb-4">
<div class="bg-green-100 rounded-full p-4">
<i class="fas fa-envelope-open-text text-green-600 text-4xl" aria-hidden="true"></i>
</div>
</div>
<p class="text-green-700 font-semibold mb-2">Check your inbox</p>
<p class="text-gray-500 text-sm mb-4">
If an account exists for that email address, a password reset link has been sent. The link expires in 24 hours.
</p>
<a href="/login"
class="inline-block py-2 px-6 rounded-md bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
style="min-height:44px;display:flex;align-items:center;justify-content:center;"
>Back to sign in</a>
</div>
<form x-show="!success" @submit.prevent="submit" class="space-y-4" novalidate>
<div x-show="error" x-cloak
class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded"
role="alert" aria-live="polite">
<p x-text="error"></p>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">
Email address <span aria-hidden="true" class="text-red-500">*</span>
</label>
<input
type="email" id="email" name="email" required
x-model="email"
autocomplete="email"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring focus:ring-indigo-500 focus:ring-opacity-50"
style="min-height:44px;"
aria-required="true"
placeholder="you@example.com"
>
</div>
<button
type="submit"
:disabled="loading"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
style="min-height:44px;"
>
<span x-show="!loading">Send reset link</span>
<span x-show="loading" x-cloak>
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>Sending&hellip;
</span>
</button>
</form>
</div>
<div class="mt-6 text-center">
<a href="/login" class="text-sm font-medium text-blue-600 hover:text-blue-500">
<i class="fas fa-arrow-left mr-1" aria-hidden="true"></i> Back to sign in
</a>
</div>
</main>
<div class="fixed bottom-4 text-center w-full text-xs text-gray-500">
DocuElevate {{ app_version|default('', true) }}
</div>
</body>
</html>
+125
View File
@@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuElevate - Forgot Username</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center py-8">
<main class="bg-white rounded-lg shadow-lg p-8 max-w-md w-full" role="main">
<div class="flex justify-center mb-6">
<img src="/static/images/logo_writing.svg" alt="DocuElevate Logo" class="h-16">
</div>
<h1 class="text-2xl font-bold text-center text-gray-800 mb-2">Forgot your username?</h1>
<p class="text-center text-gray-500 text-sm mb-6">
Enter the email address associated with your account and we'll send you your username.
You can also sign in directly with your email address.
</p>
<div
x-data="{
email: '',
loading: false,
error: '',
success: false,
async submit() {
this.error = '';
this.loading = true;
try {
const resp = await fetch('/api/auth/forgot-username', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': '{{ csrf_token }}'
},
body: JSON.stringify({ email: this.email })
});
if (resp.ok) {
this.success = true;
} else {
const data = await resp.json().catch(() => ({}));
this.error = data.detail || 'Something went wrong. Please try again.';
}
} catch(e) {
this.error = 'Network error. Please try again.';
} finally {
this.loading = false;
}
}
}"
>
<div x-show="success" x-cloak class="text-center py-4">
<div class="flex justify-center mb-4">
<div class="bg-green-100 rounded-full p-4">
<i class="fas fa-envelope-open-text text-green-600 text-4xl" aria-hidden="true"></i>
</div>
</div>
<p class="text-green-700 font-semibold mb-2">Check your inbox</p>
<p class="text-gray-500 text-sm mb-4">
If an account exists for that email address, your username has been sent.
Remember: you can also sign in using your email address directly.
</p>
<a href="/login"
class="inline-block py-2 px-6 rounded-md bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
style="min-height:44px;display:flex;align-items:center;justify-content:center;"
>Back to sign in</a>
</div>
<form x-show="!success" @submit.prevent="submit" class="space-y-4" novalidate>
<div x-show="error" x-cloak
class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded"
role="alert" aria-live="polite">
<p x-text="error"></p>
</div>
<div class="bg-blue-50 border border-blue-200 rounded-md p-3 text-sm text-blue-700">
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
<strong>Tip:</strong> You can sign in with either your username <em>or</em> your email address — no lookup needed.
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">
Email address <span aria-hidden="true" class="text-red-500">*</span>
</label>
<input
type="email" id="email" name="email" required
x-model="email"
autocomplete="email"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring focus:ring-indigo-500 focus:ring-opacity-50"
style="min-height:44px;"
aria-required="true"
placeholder="you@example.com"
>
</div>
<button
type="submit"
:disabled="loading"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
style="min-height:44px;"
>
<span x-show="!loading">Send username reminder</span>
<span x-show="loading" x-cloak>
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>Sending&hellip;
</span>
</button>
</form>
</div>
<div class="mt-6 text-center">
<a href="/login" class="text-sm font-medium text-blue-600 hover:text-blue-500">
<i class="fas fa-arrow-left mr-1" aria-hidden="true"></i> Back to sign in
</a>
</div>
</main>
<div class="fixed bottom-4 text-center w-full text-xs text-gray-500">
DocuElevate {{ app_version|default('', true) }}
</div>
</body>
</html>
+11 -1
View File
@@ -35,8 +35,9 @@
<form method="POST" action="/auth" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token | default('', true) }}">
<div>
<label for="username" class="block text-sm font-medium text-gray-700">Username</label>
<label for="username" class="block text-sm font-medium text-gray-700">Username or Email</label>
<input type="text" id="username" name="username" required
autocomplete="username"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50">
</div>
@@ -50,6 +51,15 @@
Sign in
</button>
</form>
<div class="mt-3 text-center space-x-3">
<a href="/forgot-password" class="text-sm text-blue-600 hover:text-blue-500">
Forgot password?
</a>
<span class="text-gray-300" aria-hidden="true">|</span>
<a href="/forgot-username" class="text-sm text-blue-600 hover:text-blue-500">
Forgot username?
</a>
</div>
</div>
{% if show_oauth %}