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);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{C as e,F as t,J as n,K as r,N as i,U as a,Y as o,f as s,g as c,h as l,p as u,q as d,t as f,v as p,x as m,y as h}from"./_plugin-vue_export-helper-BNDVmFr7.js";import{n as g,r as _,t as v}from"./index-DdJ5jHP4.js";var y=a(null),b=a(!1);function x(){return typeof window>`u`?!1:window.matchMedia?.(`(display-mode: standalone)`).matches?!0:window.navigator.standalone===!0}function S(){if(typeof navigator>`u`)return!1;let e=navigator.userAgent,t=e.includes(`Mac`)&&navigator.maxTouchPoints>1;return/iPhone|iPod/.test(e)||t}var C=!1;function w(){C||typeof window>`u`||(C=!0,b.value=x(),window.addEventListener(`beforeinstallprompt`,e=>{e.preventDefault(),y.value=e}),window.addEventListener(`appinstalled`,()=>{y.value=null,b.value=!0}),window.matchMedia?.(`(display-mode: standalone)`).addEventListener(`change`,e=>{b.value=e.matches}))}w();function T(){let e=S(),t=l(()=>y.value!==null);async function n(){let e=y.value;if(!e)return!1;await e.prompt();let t=await e.userChoice;return y.value=null,t.outcome===`accepted`}return{isStandalone:b,isIOS:e,canPromptInstall:t,install:n}}var E={class:`settings`},D={key:0,class:`settings__section`},O={class:`settings__section`},k={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},A=[`aria-checked`,`aria-label`,`onClick`],j={class:`theme-swatch__label`},M={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},N={class:`settings__section`},P={class:`settings__row`},F={class:`settings__row-value`},I={class:`install-modal__card`},L={id:`install-modal-title`,class:`install-modal__title`},R={class:`install-modal__steps`},z={key:0},B={key:1},V={key:0},H=f(e({__name:`SettingsView`,setup(e){let f=_(),{saveTheme:y}=g(),{isStandalone:b,isIOS:x,canPromptInstall:S,install:C}=T(),w=l(()=>f.user?.theme??`warm-craft`),H=a(!1);function U(e){y(e)}async function W(){!await C()&&!S.value&&(H.value=!0)}return(e,a)=>(i(),h(`main`,E,[a[18]||=c(`h1`,{class:`settings__title`},`Settings`,-1),r(b)?p(``,!0):(i(),h(`section`,D,[a[3]||=c(`h2`,{class:`settings__section-title`},`Install app`,-1),a[4]||=c(`p`,{class:`settings__hint`},` Pin pictureFrame to your home screen so it opens like a native app. `,-1),r(S)?(i(),h(`button`,{key:0,type:`button`,class:`settings__install`,onClick:W},` Install pictureFrame `)):(i(),h(`button`,{key:1,type:`button`,class:`settings__install`,onClick:a[0]||=e=>H.value=!0},` Add to Home Screen `))])),c(`section`,O,[a[6]||=c(`h2`,{class:`settings__section-title`},`Theme`,-1),c(`div`,k,[(i(!0),h(u,null,t(r(v),e=>(i(),h(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":w.value===e.id,"aria-label":e.label,class:d([`theme-swatch`,{"theme-swatch--active":w.value===e.id}]),style:n({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>U(e.id)},[a[5]||=c(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[c(`span`,{class:`theme-swatch__bar`}),c(`span`,{class:`theme-swatch__dot`})],-1),c(`span`,j,o(e.label),1),w.value===e.id?(i(),h(`span`,M,``)):p(``,!0)],14,A))),128))])]),c(`section`,N,[a[8]||=c(`h2`,{class:`settings__section-title`},`Account`,-1),c(`div`,P,[a[7]||=c(`span`,{class:`settings__row-label`},`Signed in as`,-1),c(`span`,F,o(r(f).user?.email),1)]),a[9]||=c(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)]),H.value?(i(),h(`div`,{key:1,class:`install-modal`,role:`dialog`,"aria-modal":`true`,"aria-labelledby":`install-modal-title`,onClick:a[2]||=s(e=>H.value=!1,[`self`])},[c(`div`,I,[c(`button`,{type:`button`,class:`install-modal__close`,"aria-label":`Close`,onClick:a[1]||=e=>H.value=!1},`×`),c(`h2`,L,o(r(x)?`Add to your iPhone home screen`:`Add to your home screen`),1),c(`ol`,R,[r(x)?(i(),h(`li`,z,[...a[10]||=[m(` Tap the `,-1),c(`strong`,null,`Share`,-1),m(` icon at the bottom of Safari (the square with an up-arrow). `,-1)]])):(i(),h(`li`,B,[...a[11]||=[m(` Open your browser's menu (usually the three dots `,-1),c(`strong`,null,``,-1),m(` in the top right). `,-1)]])),c(`li`,null,[a[13]||=m(` Scroll down and tap `,-1),a[14]||=c(`strong`,null,`Add to Home Screen`,-1),r(x)?p(``,!0):(i(),h(`span`,V,[...a[12]||=[m(`or `,-1),c(`strong`,null,`Install app`,-1)]])),a[15]||=m(`. `,-1)]),a[16]||=c(`li`,null,[m(` Tap `),c(`strong`,null,`Add`),m(` in the top right to confirm. `)],-1)]),a[17]||=c(`p`,{class:`install-modal__footer`},` The app will appear on your home screen. Open it from there and it runs like a regular app — no address bar, no tabs. `,-1)])])):p(``,!0)]))}}),[[`__scopeId`,`data-v-fb5d8496`]]);export{H as default};
@@ -1 +0,0 @@
.settings[data-v-fb5d8496]{padding:var(--space-4) var(--space-4) calc(var(--bottom-nav-height) + var(--space-6));max-width:480px;margin:0 auto}.settings__title[data-v-fb5d8496]{font-size:var(--text-xl);margin-bottom:var(--space-6);font-weight:700}.settings__section[data-v-fb5d8496]{margin-bottom:var(--space-6)}.settings__section-title[data-v-fb5d8496]{font-size:var(--text-sm);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:var(--space-3);font-weight:700}.settings__row[data-v-fb5d8496]{padding:var(--space-3) 0;border-bottom:1px solid var(--color-border);font-size:var(--text-base);justify-content:space-between;align-items:center;display:flex}.settings__row-label[data-v-fb5d8496]{color:var(--color-text-muted)}.settings__row-value[data-v-fb5d8496]{font-weight:600}.settings__logout[data-v-fb5d8496]{min-height:var(--touch-min);padding:var(--space-3) 0;color:var(--color-destructive);font-weight:600;font-size:var(--text-base);align-items:center;text-decoration:none;display:flex}.settings__hint[data-v-fb5d8496]{color:var(--color-text-muted);font-size:var(--text-sm);margin-bottom:var(--space-3);line-height:1.4}.settings__install[data-v-fb5d8496]{width:100%;min-height:var(--touch-min);background:var(--color-primary);color:var(--color-on-primary);border-radius:var(--radius-pill,9999px);font-size:var(--text-base);cursor:pointer;border:none;justify-content:center;align-items:center;font-weight:700;display:flex}.install-modal[data-v-fb5d8496]{padding:var(--space-4);z-index:100;background:#00000080;justify-content:center;align-items:center;display:flex;position:fixed;inset:0}.install-modal__card[data-v-fb5d8496]{background:var(--color-surface);color:var(--color-text);border-radius:var(--radius-lg,16px);padding:var(--space-5);width:100%;max-width:380px;position:relative;box-shadow:0 20px 60px #00000040}.install-modal__close[data-v-fb5d8496]{top:var(--space-2);right:var(--space-3);color:var(--color-text-muted);cursor:pointer;padding:var(--space-1) var(--space-2);background:0 0;border:none;font-size:1.75rem;line-height:1;position:absolute}.install-modal__title[data-v-fb5d8496]{font-size:var(--text-lg);margin-bottom:var(--space-3);padding-right:var(--space-5);font-weight:700}.install-modal__steps[data-v-fb5d8496]{margin:0 0 var(--space-3) var(--space-4);padding:0;line-height:1.5}.install-modal__steps li[data-v-fb5d8496]{margin-bottom:var(--space-2)}.install-modal__footer[data-v-fb5d8496]{color:var(--color-text-muted);font-size:var(--text-sm);margin-top:var(--space-3);border-top:1px solid var(--color-border);padding-top:var(--space-3);line-height:1.4}.theme-grid[data-v-fb5d8496]{gap:var(--space-3);grid-template-columns:repeat(3,1fr);display:grid}.theme-swatch[data-v-fb5d8496]{align-items:center;gap:var(--space-2);padding:var(--space-3);background:var(--swatch-bg);border-radius:var(--radius-md);cursor:pointer;transition:border-color var(--duration-fast);min-height:var(--touch-min);border:2px solid #0000;flex-direction:column;display:flex;position:relative}.theme-swatch--active[data-v-fb5d8496]{border-color:var(--swatch-primary)}.theme-swatch__preview[data-v-fb5d8496]{border-radius:var(--radius-sm);background:var(--swatch-bg);border:1px solid color-mix(in srgb, var(--swatch-text) 15%, transparent);flex-direction:column;justify-content:center;gap:4px;width:100%;height:36px;padding:0 6px;display:flex}.theme-swatch__bar[data-v-fb5d8496]{background:var(--swatch-primary);border-radius:3px;width:60%;height:6px;display:block}.theme-swatch__dot[data-v-fb5d8496]{background:var(--swatch-text);opacity:.4;border-radius:2px;width:80%;height:4px;display:block}.theme-swatch__label[data-v-fb5d8496]{font-size:var(--text-xs);color:var(--color-text);text-align:center;font-weight:600;line-height:1.2}.theme-swatch__check[data-v-fb5d8496]{top:var(--space-1);right:var(--space-2);font-size:var(--text-sm);color:var(--swatch-primary);font-weight:700;position:absolute}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -14,7 +14,7 @@
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
<script type="module" crossorigin src="/build/assets/index-DdJ5jHP4.js"></script>
<script type="module" crossorigin src="/build/assets/index-BA_yAOa7.js"></script>
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-BNDVmFr7.js">
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
</head>
+30
View File
@@ -10,6 +10,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -72,6 +73,35 @@ class UserApiController extends AbstractController
return $this->json(['theme' => $theme]);
}
#[Route('/password', name: 'api_user_password', methods: ['PATCH'])]
public function updatePassword(
Request $request,
EntityManagerInterface $em,
UserPasswordHasherInterface $hasher,
): JsonResponse {
$body = json_decode($request->getContent(), true);
$currentPassword = $body['currentPassword'] ?? null;
$newPassword = $body['newPassword'] ?? null;
if (!is_string($currentPassword) || !is_string($newPassword)) {
return $this->json(['error' => 'Both currentPassword and newPassword are required'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
if (strlen($newPassword) < 8) {
return $this->json(['error' => 'New password must be at least 8 characters'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
/** @var User $user */
$user = $this->getUser();
if (!$hasher->isPasswordValid($user, $currentPassword)) {
return $this->json(['error' => 'Current password is incorrect'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$user->setPassword($hasher->hashPassword($user, $newPassword));
$em->flush();
return $this->json(null, Response::HTTP_NO_CONTENT);
}
#[Route('/timezone', name: 'api_user_timezone', methods: ['PATCH'])]
public function updateTimezone(Request $request, EntityManagerInterface $em): JsonResponse
{
@@ -115,4 +115,66 @@ class UserApiControllerTest extends AppWebTestCase
$this->assertResponseRedirects('/login');
}
// US-09: updatePassword valid → 204 and the new password works for re-login
public function test_update_password_valid_returns_204_and_rehashes(): void
{
$user = $this->createUser('us09@example.com', 'currentPass1');
$client = $this->loginAs($user);
$client->request('PATCH', '/api/user/password', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['currentPassword' => 'currentPass1', 'newPassword' => 'brandNew2025']));
$this->assertResponseStatusCodeSame(204);
// Re-fetch the user; the stored hash must accept the new password and
// reject the old one.
$hasher = static::getContainer()->get('security.user_password_hasher');
$em = static::getContainer()->get('doctrine.orm.entity_manager');
$em->clear();
$reloaded = $em->getRepository(\App\Entity\User::class)->findOneBy(['email' => 'us09@example.com']);
$this->assertNotNull($reloaded);
$this->assertTrue($hasher->isPasswordValid($reloaded, 'brandNew2025'));
$this->assertFalse($hasher->isPasswordValid($reloaded, 'currentPass1'));
}
// US-10: updatePassword wrong current → 422
public function test_update_password_wrong_current_returns_422(): void
{
$user = $this->createUser('us10@example.com', 'rightPass1');
$client = $this->loginAs($user);
$client->request('PATCH', '/api/user/password', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['currentPassword' => 'wrongPass1', 'newPassword' => 'brandNew2025']));
$this->assertResponseStatusCodeSame(422);
}
// US-11: updatePassword too-short new → 422
public function test_update_password_short_new_returns_422(): void
{
$user = $this->createUser('us11@example.com', 'currentPass1');
$client = $this->loginAs($user);
$client->request('PATCH', '/api/user/password', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['currentPassword' => 'currentPass1', 'newPassword' => 'short']));
$this->assertResponseStatusCodeSame(422);
}
// US-12: updatePassword missing fields → 422
public function test_update_password_missing_fields_returns_422(): void
{
$user = $this->createUser('us12@example.com', 'currentPass1');
$client = $this->loginAs($user);
$client->request('PATCH', '/api/user/password', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['newPassword' => 'brandNew2025']));
$this->assertResponseStatusCodeSame(422);
}
}