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:
@@ -55,9 +55,73 @@
|
|||||||
<span class="settings__row-label">Signed in as</span>
|
<span class="settings__row-label">Signed in as</span>
|
||||||
<span class="settings__row-value">{{ auth.user?.email }}</span>
|
<span class="settings__row-value">{{ auth.user?.email }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="settings__action-link" @click="passwordModalOpen = true">
|
||||||
|
Change password
|
||||||
|
</button>
|
||||||
<a href="/logout" class="settings__logout">Sign out</a>
|
<a href="/logout" class="settings__logout">Sign out</a>
|
||||||
</section>
|
</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
|
<div
|
||||||
v-if="showIosInstructions"
|
v-if="showIosInstructions"
|
||||||
class="install-modal"
|
class="install-modal"
|
||||||
@@ -128,6 +192,65 @@ async function onNativeInstall() {
|
|||||||
showIosInstructions.value = true
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -178,6 +301,23 @@ async function onNativeInstall() {
|
|||||||
font-size: var(--text-base);
|
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 {
|
&__hint {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: var(--text-sm);
|
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 {
|
.theme-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+1
-1
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}
|
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -14,7 +14,7 @@
|
|||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<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-status-bar-style" content="default" />
|
||||||
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
|
<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="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-BNDVmFr7.js">
|
||||||
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
|
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
@@ -72,6 +73,35 @@ class UserApiController extends AbstractController
|
|||||||
return $this->json(['theme' => $theme]);
|
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'])]
|
#[Route('/timezone', name: 'api_user_timezone', methods: ['PATCH'])]
|
||||||
public function updateTimezone(Request $request, EntityManagerInterface $em): JsonResponse
|
public function updateTimezone(Request $request, EntityManagerInterface $em): JsonResponse
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -115,4 +115,66 @@ class UserApiControllerTest extends AppWebTestCase
|
|||||||
|
|
||||||
$this->assertResponseRedirects('/login');
|
$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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user