From c3fe52589f556dafe1bc164c8527c43f2e6637cb Mon Sep 17 00:00:00 2001 From: makearmy Date: Tue, 30 Sep 2025 22:51:12 -0400 Subject: [PATCH] more password change fixes --- app/api/account/password/route.ts | 100 ++++++++++++++++---------- components/account/PasswordChange.tsx | 67 +++++++++++++---- 2 files changed, 119 insertions(+), 48 deletions(-) diff --git a/app/api/account/password/route.ts b/app/api/account/password/route.ts index 954473ea..e46dbfca 100644 --- a/app/api/account/password/route.ts +++ b/app/api/account/password/route.ts @@ -5,59 +5,87 @@ import { loginDirectus } from "@/lib/directus"; export const runtime = "nodejs"; -const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""); -const bad = (m: string, c = 400) => NextResponse.json({ error: m }, { status: c }); +const API = (process.env.NEXT_PUBLIC_API_BASE_URL || process.env.DIRECTUS_URL || "").replace(/\/$/, ""); +const bad = (m: string, c = 400, extra?: Record) => +NextResponse.json(extra ? { error: m, ...extra } : { error: m }, { status: c }); + +function readCookie(req: Request, name: string): string | null { + const cookie = req.headers.get("cookie") || ""; + const m = cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`)); + return m ? decodeURIComponent(m[1]) : null; +} + +function jwtPayload(token: string | null): any | null { + if (!token) return null; + try { + const [, payload] = token.split("."); + if (!payload) return null; + const json = JSON.parse( + Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8") + ); + return json; + } catch { + return null; + } +} async function handle(req: Request) { const bearer = requireBearer(req); - const body = await req.json().catch(() => ({})); + const body = await req.json().catch(() => ({} as any)); const current = String(body?.current ?? body?.current_password ?? "").trim(); const next = String(body?.next ?? body?.new_password ?? "").trim(); + // NEW: allow client to provide identifier explicitly + let identifier = String(body?.identifier ?? "").trim(); if (!current || !next) return bad("Missing current and/or new password"); if (next.length < 8) return bad("Password must be at least 8 characters"); - // 1) Load current user to get provider + identifiers - const meRes = await fetch(`${API}/users/me?fields=id,email,username,provider`, { - headers: { Authorization: `Bearer ${bearer}`, Accept: "application/json" }, - cache: "no-store", - }); - const me = await meRes.json().catch(() => ({})); - if (!meRes.ok) { - return NextResponse.json( - { error: "Could not verify user", debug: me?.errors?.[0]?.message || meRes.statusText }, - { status: meRes.status } - ); - } - - const provider: string = me?.data?.provider ?? me?.provider ?? "local"; - const email: string | undefined = me?.data?.email ?? me?.email ?? undefined; - const username: string | undefined = me?.data?.username ?? me?.username ?? undefined; - - if (provider !== "local") { - return NextResponse.json( - { error: "Password managed by external provider", debug: `provider=${provider}` }, - { status: 400 } - ); - } - - // 2) Verify CURRENT password by logging in with email OR username - const identifier = email || username; + // 1) Prefer client-provided identifier if present + // (email or username — whatever your Directus login accepts) if (!identifier) { - return NextResponse.json( - { error: "No login identifier available for this user", debug: "missing email and username" }, - { status: 400 } - ); + // 2) Try to read from /users/me (email or username), honoring role perms + const meRes = await fetch(`${API}/users/me?fields=id,email,username,provider`, { + headers: { Authorization: `Bearer ${bearer}`, Accept: "application/json" }, + cache: "no-store", + }); + const me = await meRes.json().catch(() => ({})); + if (meRes.ok) { + const provider: string = me?.data?.provider ?? me?.provider ?? "local"; + if (provider !== "local") { + return bad("Password managed by external provider", 400, { debug: `provider=${provider}` }); + } + const email: string | undefined = me?.data?.email ?? me?.email ?? undefined; + const username: string | undefined = me?.data?.username ?? me?.username ?? undefined; + identifier = email || username || ""; + } else { + // If we can’t read user fields (permissions), keep going + } } + if (!identifier) { + // 3) Try to decode JWT in ma_at — some Directus builds include email in JWT + const token = readCookie(req, "ma_at"); + const claims = jwtPayload(token); + const fromJwt = (claims?.email as string) || (claims?.user?.email as string) || ""; + identifier = fromJwt?.trim() || ""; + } + + if (!identifier) { + // Last resort: ask client to pass identifier next time + return bad("No login identifier available for this user", 400, { + debug: "missing email and username; pass `identifier` in request body", + }); + } + + // 4) Verify CURRENT password by logging in with identifier const auth = await loginDirectus(identifier, current).catch(() => null); const access = auth?.access_token ?? auth?.data?.access_token; if (!access) { - return NextResponse.json({ error: "Current password is incorrect" }, { status: 401 }); + return bad("Current password is incorrect", 401); } - // 3) Update password using ONLY the 'password' field + // 5) Update password using ONLY the 'password' field const patchRes = await fetch(`${API}/users/me`, { method: "PATCH", headers: { @@ -72,7 +100,7 @@ async function handle(req: Request) { if (!patchRes.ok) { const reason = j?.errors?.[0]?.message || j?.error || (typeof j === "string" ? j : "") || "Password change failed"; - return NextResponse.json({ error: reason }, { status: patchRes.status }); + return bad(reason, patchRes.status); } return NextResponse.json({ ok: true }); diff --git a/components/account/PasswordChange.tsx b/components/account/PasswordChange.tsx index ec68fa81..958b7696 100644 --- a/components/account/PasswordChange.tsx +++ b/components/account/PasswordChange.tsx @@ -1,15 +1,44 @@ // components/account/PasswordChange.tsx "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; + +type Me = { + id: string; + username?: string | null; + email?: string | null; +}; export default function PasswordChange() { const [current, setCurrent] = useState(""); const [next, setNext] = useState(""); const [next2, setNext2] = useState(""); + const [identifier, setIdentifier] = useState(""); // email OR username (sent to API) + const [needIdentifier, setNeedIdentifier] = useState(false); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState(null); + // Try to auto-fill identifier from /api/account + useEffect(() => { + (async () => { + try { + const r = await fetch("/api/account", { credentials: "include", cache: "no-store" }); + const j = await r.json().catch(() => ({})); + const me: Me | undefined = j?.user ?? j?.data ?? undefined; + const id = (me?.email || me?.username || "").trim(); + if (id) { + setIdentifier(id); + setNeedIdentifier(false); + } else { + setNeedIdentifier(true); + } + } catch { + // If it fails, we'll let the user type it + setNeedIdentifier(true); + } + })(); + }, []); + const onSave = async () => { setMsg(null); if (next !== next2) { @@ -20,26 +49,23 @@ export default function PasswordChange() { setMsg("Password must be at least 8 characters."); return; } + if (!identifier.trim()) { + setNeedIdentifier(true); + setMsg("Please enter your email or username."); + return; + } + setBusy(true); try { const r = await fetch("/api/account/password", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ current, next }), + body: JSON.stringify({ current, next, identifier: identifier.trim() }), }); - // ⬇️ Parse JSON **then** use debug if present const j = await r.json().catch(() => ({} as any)); - if (!r.ok) { - // ⬇️ This is the "debug block" so you can see the upstream reason - setMsg( - j?.error - ? j?.debug - ? `${j.error} (${j.debug})` - : j.error - : "Password change failed" - ); + setMsg(j?.error ? (j?.debug ? `${j.error} (${j.debug})` : j.error) : "Password change failed"); return; } @@ -59,6 +85,20 @@ export default function PasswordChange() {

Change Password

+ {needIdentifier && ( + + )} + @@ -76,6 +117,7 @@ export default function PasswordChange() { type="password" value={next} onChange={(e) => setNext(e.target.value)} + autoComplete="new-password" /> @@ -86,6 +128,7 @@ export default function PasswordChange() { type="password" value={next2} onChange={(e) => setNext2(e.target.value)} + autoComplete="new-password" />