feat(account): change-password endpoint + Settings modal
CI / test (push) Has been cancelled

PATCH /api/user/password — verifies the current password, enforces
8-char minimum on the new one, and rehashes via the configured
password hasher. Returns 204 on success, 422 with an `error` body
on every validation failure (wrong current, too-short new, missing
fields).

Settings adds a "Change password" link under the Account section
that opens a modal with current/new/confirm fields and posts to the
new endpoint. Confirm-mismatch and submit-disabled wiring is
client-side; backend errors surface inline.

Tests: 4 new controller tests cover success, wrong-current,
short-new, and missing-fields; success path also re-fetches the
user and checks the hash actually changed.
This commit is contained in:
2026-05-09 15:25:54 -04:00
parent bdb717de2e
commit 2adb07518c
12 changed files with 295 additions and 8 deletions
+195
View File
@@ -55,9 +55,73 @@
<span class="settings__row-label">Signed in as</span>
<span class="settings__row-value">{{ auth.user?.email }}</span>
</div>
<button type="button" class="settings__action-link" @click="passwordModalOpen = true">
Change password
</button>
<a href="/logout" class="settings__logout">Sign out</a>
</section>
<div
v-if="passwordModalOpen"
class="install-modal"
role="dialog"
aria-modal="true"
aria-labelledby="pw-modal-title"
@click.self="closePasswordModal"
>
<div class="install-modal__card">
<button type="button" class="install-modal__close" aria-label="Close" @click="closePasswordModal">×</button>
<h2 id="pw-modal-title" class="install-modal__title">Change password</h2>
<form class="pw-form" @submit.prevent="submitPasswordChange">
<label class="pw-form__field">
<span class="pw-form__label">Current password</span>
<input
v-model="pwCurrent"
type="password"
autocomplete="current-password"
required
class="pw-form__input"
/>
</label>
<label class="pw-form__field">
<span class="pw-form__label">New password</span>
<input
v-model="pwNew"
type="password"
autocomplete="new-password"
minlength="8"
required
class="pw-form__input"
/>
<span class="pw-form__hint">At least 8 characters.</span>
</label>
<label class="pw-form__field">
<span class="pw-form__label">Confirm new password</span>
<input
v-model="pwConfirm"
type="password"
autocomplete="new-password"
required
class="pw-form__input"
:aria-invalid="pwConfirmMismatch ? 'true' : 'false'"
/>
<span v-if="pwConfirmMismatch" class="pw-form__error">Passwords don't match.</span>
</label>
<p v-if="pwError" class="pw-form__error" role="alert">{{ pwError }}</p>
<p v-if="pwSuccess" class="pw-form__success" role="status">Password updated.</p>
<button
type="submit"
class="settings__install"
:disabled="pwSubmitting || pwConfirmMismatch || !pwCurrent || !pwNew"
>
{{ pwSubmitting ? 'Saving…' : 'Update password' }}
</button>
</form>
</div>
</div>
<div
v-if="showIosInstructions"
class="install-modal"
@@ -128,6 +192,65 @@ async function onNativeInstall() {
showIosInstructions.value = true
}
}
// ── Change password ──────────────────────────────────────────────────────────
const passwordModalOpen = ref(false)
const pwCurrent = ref('')
const pwNew = ref('')
const pwConfirm = ref('')
const pwSubmitting = ref(false)
const pwError = ref<string | null>(null)
const pwSuccess = ref(false)
const pwConfirmMismatch = computed(() =>
pwConfirm.value.length > 0 && pwConfirm.value !== pwNew.value,
)
function resetPasswordForm() {
pwCurrent.value = ''
pwNew.value = ''
pwConfirm.value = ''
pwError.value = null
pwSuccess.value = false
pwSubmitting.value = false
}
function closePasswordModal() {
passwordModalOpen.value = false
resetPasswordForm()
}
async function submitPasswordChange() {
if (pwConfirmMismatch.value) return
pwError.value = null
pwSuccess.value = false
pwSubmitting.value = true
try {
const res = await fetch('/api/user/password', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
currentPassword: pwCurrent.value,
newPassword: pwNew.value,
}),
})
if (res.status === 204) {
pwSuccess.value = true
pwCurrent.value = ''
pwNew.value = ''
pwConfirm.value = ''
// Auto-close after a moment so the user sees the confirmation.
setTimeout(closePasswordModal, 1500)
return
}
const body = await res.json().catch(() => ({}))
pwError.value = body?.error ?? 'Could not update password.'
} catch {
pwError.value = 'Network error. Try again.'
} finally {
pwSubmitting.value = false
}
}
</script>
<style scoped lang="scss">
@@ -178,6 +301,23 @@ async function onNativeInstall() {
font-size: var(--text-base);
}
&__action-link {
display: flex;
align-items: center;
width: 100%;
min-height: var(--touch-min);
padding: var(--space-3) 0;
border: none;
border-bottom: 1px solid var(--color-border);
background: transparent;
color: var(--color-primary);
font-weight: 600;
font-size: var(--text-base);
cursor: pointer;
text-align: left;
font-family: inherit;
}
&__hint {
color: var(--color-text-muted);
font-size: var(--text-sm);
@@ -262,6 +402,61 @@ async function onNativeInstall() {
}
}
.pw-form {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-top: var(--space-2);
&__field {
display: flex;
flex-direction: column;
gap: 4px;
}
&__label {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
}
&__input {
min-height: var(--touch-min);
padding: 0 var(--space-3);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
font-size: 16px;
font-family: inherit;
&:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
&[aria-invalid="true"] {
border-color: var(--color-destructive, #c0392b);
}
}
&__hint {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
&__error {
font-size: var(--text-sm);
color: var(--color-destructive, #c0392b);
}
&__success {
font-size: var(--text-sm);
color: var(--color-success, #2e7d32);
font-weight: 600;
}
}
.theme-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);