From 86fdd403b0594a8a8f2bddba7de62fe9699939c2 Mon Sep 17 00:00:00 2001 From: makearmy Date: Tue, 30 Sep 2025 19:35:27 -0400 Subject: [PATCH] account management upgrades --- app/api/account/route.ts | 35 +++++- app/api/auth/reconfirm/route.ts | 13 ++- app/portal/account/AccountPanel.tsx | 64 ++++++---- app/portal/account/page.tsx | 4 +- components/account/AvatarUploader.tsx | 86 ++++++++++++++ components/account/PasswordChange.tsx | 100 ++++++++++++++++ components/account/ProfileEditor.tsx | 161 ++++++++++++++++++++++++++ middleware.ts | 22 +--- 8 files changed, 439 insertions(+), 46 deletions(-) create mode 100644 components/account/AvatarUploader.tsx create mode 100644 components/account/PasswordChange.tsx create mode 100644 components/account/ProfileEditor.tsx diff --git a/app/api/account/route.ts b/app/api/account/route.ts index 5b524f3c..4a2efa33 100644 --- a/app/api/account/route.ts +++ b/app/api/account/route.ts @@ -6,6 +6,7 @@ import { requireBearer } from "@/app/api/_lib/auth"; const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""); const bad = (m: string, c = 400) => NextResponse.json({ error: m }, { status: c }); +const secure = process.env.NODE_ENV === "production"; /** GET: current user's profile */ export async function GET(req: Request) { @@ -36,17 +37,32 @@ return NextResponse.json({ ok: true, user: j?.data ?? j }); export async function PATCH(req: Request) { try { const bearer = requireBearer(req); - const body = await req.json().catch(() => ({})); + const body = await req.json().catch(() => ({} as Record)); + + // Enforce recent re-auth for sensitive fields (email/username) + const SENSITIVE = new Set(["email", "username"]); + const wantsSensitive = Object.keys(body).some((k) => SENSITIVE.has(k)); + if (wantsSensitive) { + const cookie = req.headers.get("cookie") || ""; + const hasRecentAuth = /(?:^|;\s*)ma_ra=1(?:;|$)/.test(cookie); + if (!hasRecentAuth) { + return NextResponse.json( + { error: "Re-authentication required" }, + { status: 428 } // Precondition Required + ); + } + } const payload: Record = {}; if (typeof body.first_name === "string") payload.first_name = body.first_name.trim(); if (typeof body.last_name === "string") payload.last_name = body.last_name.trim(); if ("email" in body) { - const e = String(body.email ?? "").trim(); + const e = String((body as any).email ?? "").trim(); payload.email = e ? e : null; // optional; blank clears } if (typeof body.location === "string") payload.location = body.location.trim(); if (typeof body.avatar === "string") payload.avatar = body.avatar; // file id + // (username is read-only in UI; if you decide to allow it later, payload.username = ...) if (!Object.keys(payload).length) return bad("No changes"); @@ -62,6 +78,21 @@ export async function PATCH(req: Request) { return NextResponse.json({ error: msg }, { status: res.status }); } + // Success: if we just did a sensitive change, clear recent-auth cookie (single-use) + if (wantsSensitive) { + const resp = NextResponse.json({ ok: true }); + resp.cookies.set({ + name: "ma_ra", + value: "", + httpOnly: false, + sameSite: "lax", + secure, + path: "/", + maxAge: 0, + }); + return resp; + } + return NextResponse.json({ ok: true }); } catch (e: any) { const status = e?.status === 401 ? 401 : e?.status || 500; diff --git a/app/api/auth/reconfirm/route.ts b/app/api/auth/reconfirm/route.ts index 42d174f0..363f72e2 100644 --- a/app/api/auth/reconfirm/route.ts +++ b/app/api/auth/reconfirm/route.ts @@ -9,14 +9,14 @@ export async function POST(req: NextRequest) { try { const body = await req.json().catch(() => ({} as any)); const identifier = String(body?.identifier ?? "").trim(); - const password = String(body?.password ?? "").trim(); + const password = String(body?.password ?? "").trim(); if (!identifier || !password) { return NextResponse.json({ error: "Missing credentials" }, { status: 400 }); } - // Resolve identifier -> email (username allowed) - let email = identifier.includes("@") ? identifier : await emailForUsername(identifier); + // Resolve identifier -> email (username or email accepted) + const email = identifier.includes("@") ? identifier : await emailForUsername(identifier); if (!email) return NextResponse.json({ error: "User not found" }, { status: 404 }); const auth = await loginDirectus(email, password); @@ -30,7 +30,8 @@ export async function POST(req: NextRequest) { const res = NextResponse.json({ ok: true }); // Refresh the access token cookie - const maxAge = typeof expiresSec === "number" ? Math.max(0, Math.floor(expiresSec)) : 60 * 60 * 8; + const maxAge = + typeof expiresSec === "number" ? Math.max(0, Math.floor(expiresSec)) : 60 * 60 * 8; res.cookies.set({ name: "ma_at", value: access, @@ -41,7 +42,7 @@ export async function POST(req: NextRequest) { maxAge, }); - // Short-lived client-visible flag: “recently authenticated” + // Short-lived client-visible flag: “recently authenticated” (5 minutes) res.cookies.set({ name: "ma_ra", value: "1", @@ -49,7 +50,7 @@ export async function POST(req: NextRequest) { sameSite: "lax", secure, path: "/", - maxAge: 5 * 60, // 5 minutes + maxAge: 5 * 60, }); return res; diff --git a/app/portal/account/AccountPanel.tsx b/app/portal/account/AccountPanel.tsx index 2d566c10..a3d5c1e2 100644 --- a/app/portal/account/AccountPanel.tsx +++ b/app/portal/account/AccountPanel.tsx @@ -1,8 +1,12 @@ // app/portal/account/AccountPanel.tsx "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState, useCallback } from "react"; +import ProfileEditor from "@/components/account/ProfileEditor"; +import PasswordChange from "@/components/account/PasswordChange"; +import AvatarUploader from "@/components/account/AvatarUploader"; +type Avatar = { id: string; filename_download?: string } | null; type Me = { id: string; username: string; @@ -10,7 +14,7 @@ type Me = { last_name?: string | null; email?: string | null; location?: string | null; - avatar?: { id: string; filename_download: string } | null; + avatar?: Avatar; }; export default function AccountPanel() { @@ -18,20 +22,34 @@ export default function AccountPanel() { const [err, setErr] = useState(null); const [loading, setLoading] = useState(true); + // Precompute API base once on the client + const API_BASE = useMemo( + () => (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""), + [] + ); + + const refetchMe = useCallback(async () => { + try { + const r = await fetch("/api/account", { + credentials: "include", + cache: "no-store", + }); + if (!r.ok) throw new Error(`Load failed (${r.status})`); + const j = await r.json(); + const user: Me | undefined = j?.user ?? j?.data ?? undefined; + if (!user) throw new Error("Malformed response"); + setMe(user); + } catch (e: any) { + setErr(e?.message || "Failed to load account"); + } + }, []); + useEffect(() => { let alive = true; (async () => { try { setErr(null); - const r = await fetch("/api/account", { - credentials: "include", - cache: "no-store", - }); - if (!r.ok) throw new Error(`Load failed (${r.status})`); - const j = await r.json(); - if (alive) setMe(j); - } catch (e: any) { - if (alive) setErr(e?.message || "Failed to load account"); + await refetchMe(); } finally { if (alive) setLoading(false); } @@ -39,16 +57,13 @@ export default function AccountPanel() { return () => { alive = false; }; - }, []); + }, [refetchMe]); if (loading) return
Loading…
; if (err) return
Error: {err}
; if (!me) return null; - const avatarUrl = - me.avatar?.id - ? `${process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/$/, "")}/assets/${me.avatar.id}` - : null; + const avatarUrl = me.avatar?.id ? `${API_BASE}/assets/${me.avatar.id}` : null; return (
@@ -64,9 +79,7 @@ export default function AccountPanel() { No Avatar )}
-
- Usernames can’t be changed. -
+
Usernames can’t be changed.
@@ -93,7 +106,18 @@ export default function AccountPanel() {
- {/* Add your edit forms/buttons below; they should only trigger reauth on submit */} + {/* Editable sections */} + + + + + ); } diff --git a/app/portal/account/page.tsx b/app/portal/account/page.tsx index 5760385b..922357f7 100644 --- a/app/portal/account/page.tsx +++ b/app/portal/account/page.tsx @@ -10,7 +10,7 @@ export default async function AccountPage() { if (!at) { redirect(`/auth/sign-in?next=${encodeURIComponent("/portal/account")}`); } - // No reauth gating here; the panel will fetch and render profile, - // and only ask for reauth when user submits sensitive changes. + // No reauth gating here; the panel fetches and renders profile, + // and only asks for reauth when the user submits sensitive changes. return ; } diff --git a/components/account/AvatarUploader.tsx b/components/account/AvatarUploader.tsx new file mode 100644 index 00000000..6bad5474 --- /dev/null +++ b/components/account/AvatarUploader.tsx @@ -0,0 +1,86 @@ +// components/account/AvatarUploader.tsx +"use client"; + +import { useMemo, useState } from "react"; + +export default function AvatarUploader({ + avatarId, + onUpdated, +}: { + avatarId?: string | null; + onUpdated?: () => void; +}) { + const [file, setFile] = useState(null); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState(null); + + const API_BASE = useMemo( + () => (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""), + [] + ); + const currentUrl = avatarId ? `${API_BASE}/assets/${avatarId}` : null; + + const onUpload = async () => { + setMsg(null); + if (!file) { + setMsg("Choose a file first."); + return; + } + setBusy(true); + try { + const fd = new FormData(); + fd.set("file", file, file.name); + + const r = await fetch("/api/account/avatar", { method: "POST", body: fd }); + if (r.status === 401) { + location.assign(`/auth/sign-in?reauth=1&next=${encodeURIComponent("/portal/account")}`); + return; + } + const j = await r.json().catch(() => ({})); + if (!r.ok) { + setMsg(j?.error || "Upload failed"); + return; + } + setMsg("Avatar updated."); + setFile(null); + onUpdated?.(); + } catch (e: any) { + setMsg(e?.message || "Upload failed"); + } finally { + setBusy(false); + } + }; + + return ( +
+

Avatar

+ +
+
+ {currentUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + Avatar + ) : ( + No Avatar + )} +
+ + setFile(e.currentTarget.files?.[0] ?? null)} + /> + +
+ + {msg &&
{msg}
} +
+ ); +} diff --git a/components/account/PasswordChange.tsx b/components/account/PasswordChange.tsx new file mode 100644 index 00000000..cc774680 --- /dev/null +++ b/components/account/PasswordChange.tsx @@ -0,0 +1,100 @@ +// components/account/PasswordChange.tsx +"use client"; + +import { useState } from "react"; + +export default function PasswordChange() { + const [current, setCurrent] = useState(""); + const [next, setNext] = useState(""); + const [next2, setNext2] = useState(""); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState(null); + + const onSave = async () => { + setMsg(null); + if (next !== next2) { + setMsg("New passwords do not match."); + return; + } + if (next.length < 8) { + setMsg("Password must be at least 8 characters."); + return; + } + setBusy(true); + try { + const r = await fetch("/api/account/password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ current, next }), + }); + if (r.status === 401) { + // Wrong current or expired token; the route returns a friendly message for wrong current. + const j = await r.json().catch(() => ({})); + setMsg(j?.error || "Re-authentication required."); + return; + } + const j = await r.json().catch(() => ({})); + if (!r.ok) { + setMsg(j?.error || "Password change failed"); + return; + } + setMsg("Password updated."); + setCurrent(""); + setNext(""); + setNext2(""); + } catch (e: any) { + setMsg(e?.message || "Password change failed"); + } finally { + setBusy(false); + } + }; + + return ( +
+

Change Password

+ +
+ + + + + +
+ +
+ + {msg &&
{msg}
} +
+
+ ); +} diff --git a/components/account/ProfileEditor.tsx b/components/account/ProfileEditor.tsx new file mode 100644 index 00000000..fe2790eb --- /dev/null +++ b/components/account/ProfileEditor.tsx @@ -0,0 +1,161 @@ +// components/account/ProfileEditor.tsx +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +type Me = { + id: string; + username: string; + first_name?: string | null; + last_name?: string | null; + email?: string | null; + location?: string | null; + avatar?: { id: string; filename_download?: string } | null; +}; + +export default function ProfileEditor({ + me: meProp, + onUpdated, +}: { + me?: Me | null; + onUpdated?: () => void; +}) { + const [me, setMe] = useState(meProp ?? null); + const [first_name, setFirst] = useState(""); + const [last_name, setLast] = useState(""); + const [email, setEmail] = useState(""); + const [location, setLocation] = useState(""); + const [msg, setMsg] = useState(null); + const [busy, setBusy] = useState(false); + const [loading, setLoading] = useState(!meProp); + + const nextAccount = "/portal/account"; + + useEffect(() => { + if (meProp) { + setMe(meProp); + setLoading(false); + } else { + (async () => { + try { + const r = await fetch("/api/account", { credentials: "include", cache: "no-store" }); + if (!r.ok) throw new Error(String(r.status)); + const j = await r.json(); + const user: Me | undefined = j?.user ?? j?.data ?? undefined; + if (!user) throw new Error("Bad response"); + setMe(user); + } catch (e: any) { + setMsg(`Failed to load: ${e?.message || e}`); + } finally { + setLoading(false); + } + })(); + } + }, [meProp]); + + useEffect(() => { + if (!me) return; + setFirst(me.first_name || ""); + setLast(me.last_name || ""); + setEmail(me.email || ""); + setLocation(me.location || ""); + }, [me]); + + const onSave = async () => { + setMsg(null); + setBusy(true); + try { + const payload: Record = { + first_name: first_name.trim(), + last_name: last_name.trim(), + email: email.trim() || null, // allow clearing email + location: location.trim(), + }; + const r = await fetch("/api/account/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (r.status === 428) { + // Need reauth for sensitive change (email) + location.assign(`/auth/sign-in?reauth=1&next=${encodeURIComponent(nextAccount + "#security")}`); + return; + } + if (r.status === 401) { + location.assign(`/auth/sign-in?reauth=1&next=${encodeURIComponent(nextAccount)}`); + return; + } + const j = await r.json().catch(() => ({})); + if (!r.ok) { + setMsg(j?.error || "Update failed"); + return; + } + setMsg("Saved."); + onUpdated?.(); + } catch (e: any) { + setMsg(e?.message || "Update failed"); + } finally { + setBusy(false); + } + }; + + if (loading) return
Loading editor…
; + + return ( +
+

Edit Profile

+ +
+ + + + + + + +
+ +
+ + {msg &&
{msg}
} +
+
+ ); +} diff --git a/middleware.ts b/middleware.ts index 1fb79fb5..c0ddfa01 100644 --- a/middleware.ts +++ b/middleware.ts @@ -18,10 +18,7 @@ import { NextResponse, NextRequest } from "next/server"; /** Directus base (used to remotely validate the token after restarts). */ const DIRECTUS = - (process.env.NEXT_PUBLIC_API_BASE_URL || process.env.DIRECTUS_URL || "").replace( - /\/$/, - "" - ); + (process.env.NEXT_PUBLIC_API_BASE_URL || process.env.DIRECTUS_URL || "").replace(/\/$/, ""); /** Helper: does the path start with any prefix in a list? */ function startsWithAny(pathname: string, prefixes: string[]) { @@ -33,8 +30,7 @@ import { NextResponse, NextRequest } from "next/server"; const dest = new URL(req.url); dest.pathname = mapped.pathname; if (mapped.query) { - for (const [k, v] of Object.entries(mapped.query)) - dest.searchParams.set(k, v); + for (const [k, v] of Object.entries(mapped.query)) dest.searchParams.set(k, v); } return dest.href === req.url; } @@ -44,9 +40,7 @@ import { NextResponse, NextRequest } from "next/server"; try { const [, payload] = token.split("."); if (!payload) return null; - const json = JSON.parse( - atob(payload.replace(/-/g, "+").replace(/_/g, "/")) - ); + const json = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/"))); return typeof json.exp === "number" ? json.exp : null; } catch { return null; @@ -81,8 +75,7 @@ import { NextResponse, NextRequest } from "next/server"; if (mapped && !isSameUrl(req, mapped)) { url.pathname = mapped.pathname; if (mapped.query) { - for (const [k, v] of Object.entries(mapped.query)) - url.searchParams.set(k, v); + for (const [k, v] of Object.entries(mapped.query)) url.searchParams.set(k, v); } return NextResponse.redirect(url); } @@ -95,8 +88,7 @@ import { NextResponse, NextRequest } from "next/server"; // Allow explicit reauth flow even if a (possibly stale) token cookie exists const forceAuth = isAuthRoute && - (url.searchParams.get("reauth") === "1" || - url.searchParams.get("force") === "1"); + (url.searchParams.get("reauth") === "1" || url.searchParams.get("force") === "1"); // If unauthenticated and the route is protected, send to sign-in (with next + reauth) if (!token && isProtected) { @@ -248,7 +240,5 @@ import { NextResponse, NextRequest } from "next/server"; } export const config = { - matcher: [ - "/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|images|static).*)", - ], + matcher: ["/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|images|static).*)"], };