diff --git a/app/api/account/route.ts b/app/api/account/route.ts index 4a2efa33..5b524f3c 100644 --- a/app/api/account/route.ts +++ b/app/api/account/route.ts @@ -6,7 +6,6 @@ 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) { @@ -37,32 +36,17 @@ 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(() => ({} 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 body = await req.json().catch(() => ({})); 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 as any).email ?? "").trim(); + const e = String(body.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"); @@ -78,21 +62,6 @@ 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 363f72e2..42d174f0 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 or email accepted) - const email = identifier.includes("@") ? identifier : await emailForUsername(identifier); + // Resolve identifier -> email (username allowed) + let 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,8 +30,7 @@ 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, @@ -42,7 +41,7 @@ export async function POST(req: NextRequest) { maxAge, }); - // Short-lived client-visible flag: “recently authenticated” (5 minutes) + // Short-lived client-visible flag: “recently authenticated” res.cookies.set({ name: "ma_ra", value: "1", @@ -50,7 +49,7 @@ export async function POST(req: NextRequest) { sameSite: "lax", secure, path: "/", - maxAge: 5 * 60, + maxAge: 5 * 60, // 5 minutes }); return res; diff --git a/app/auth/sign-in/page.tsx b/app/auth/sign-in/page.tsx index a8b565ac..88a68b22 100644 --- a/app/auth/sign-in/page.tsx +++ b/app/auth/sign-in/page.tsx @@ -3,24 +3,21 @@ import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import SignIn from "./sign-in"; -export default async function SignInPage({ - searchParams, -}: { - searchParams?: Record; -}) { - const sp = searchParams ?? {}; +export default async function SignInPage( + props: { searchParams: Promise> } +) { + const sp = await props.searchParams; const nextParam = Array.isArray(sp.next) ? sp.next[0] : sp.next; - const nextPath = - nextParam && String(nextParam).startsWith("/") ? String(nextParam) : "/portal"; + const nextPath = nextParam && nextParam.startsWith("/") ? nextParam : "/portal"; const reauthParam = Array.isArray(sp.reauth) ? sp.reauth[0] : sp.reauth; - const forceParam = Array.isArray(sp.force) ? sp.force[0] : sp.force; + const forceParam = Array.isArray(sp.force) ? sp.force[0] : sp.force; const reauth = reauthParam === "1" || forceParam === "1"; // If reauth is requested, always render the form (no redirect). if (!reauth) { - const at = cookies().get("ma_at")?.value; + const at = (await cookies()).get("ma_at")?.value; if (at) redirect("/portal"); } diff --git a/app/portal/account/AccountPanel.tsx b/app/portal/account/AccountPanel.tsx index a3d5c1e2..2d566c10 100644 --- a/app/portal/account/AccountPanel.tsx +++ b/app/portal/account/AccountPanel.tsx @@ -1,12 +1,8 @@ // app/portal/account/AccountPanel.tsx "use client"; -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"; +import { useEffect, useState } from "react"; -type Avatar = { id: string; filename_download?: string } | null; type Me = { id: string; username: string; @@ -14,7 +10,7 @@ type Me = { last_name?: string | null; email?: string | null; location?: string | null; - avatar?: Avatar; + avatar?: { id: string; filename_download: string } | null; }; export default function AccountPanel() { @@ -22,34 +18,20 @@ 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); - await refetchMe(); + 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"); } finally { if (alive) setLoading(false); } @@ -57,13 +39,16 @@ 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 ? `${API_BASE}/assets/${me.avatar.id}` : null; + const avatarUrl = + me.avatar?.id + ? `${process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/$/, "")}/assets/${me.avatar.id}` + : null; return (
@@ -79,7 +64,9 @@ export default function AccountPanel() { No Avatar )}
-
Usernames can’t be changed.
+
+ Usernames can’t be changed. +
@@ -106,18 +93,7 @@ export default function AccountPanel() {
- {/* Editable sections */} - - - - - + {/* Add your edit forms/buttons below; they should only trigger reauth on submit */} ); } diff --git a/app/portal/account/page.tsx b/app/portal/account/page.tsx index 922357f7..5760385b 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 fetches and renders profile, - // and only asks for reauth when the user submits sensitive changes. + // No reauth gating here; the panel will fetch and render profile, + // and only ask for reauth when user submits sensitive changes. return ; } diff --git a/app/submit/settings/success/page.tsx b/app/submit/settings/success/page.tsx index b8d1a900..83f68c18 100644 --- a/app/submit/settings/success/page.tsx +++ b/app/submit/settings/success/page.tsx @@ -20,17 +20,16 @@ const TARGET_LABEL: Record = { export default async function SuccessPage({ searchParams, }: { - searchParams?: Record; + // Next 15 types `searchParams` as a Promise + searchParams: Promise>; }) { - const sp = searchParams ?? {}; + const sp = await searchParams; const rawTarget = sp?.target; const rawId = sp?.id; const valid: Target[] = ["settings_fiber", "settings_co2gan", "settings_co2gal", "settings_uv"]; - const t: Target = valid.includes(rawTarget as Target) - ? (rawTarget as Target) - : "settings_fiber"; + const t: Target = (valid.includes(rawTarget as Target) ? (rawTarget as Target) : "settings_fiber"); const id = Array.isArray(rawId) ? rawId[0] : rawId || ""; const listHref = TARGET_TO_LIST[t]; @@ -39,28 +38,20 @@ export default async function SuccessPage({ return (
-
-

Submission received

+
+

Settings submitted!

Your {label} submission has been received. - {id ? ( - <> - {" "} - Reference ID: {id}. - - ) : null} + {id ? <> Reference ID: {id}. : null}

View {label} database - + Submit another diff --git a/components/account/AvatarUploader.tsx b/components/account/AvatarUploader.tsx deleted file mode 100644 index 6bad5474..00000000 --- a/components/account/AvatarUploader.tsx +++ /dev/null @@ -1,86 +0,0 @@ -// 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 deleted file mode 100644 index cc774680..00000000 --- a/components/account/PasswordChange.tsx +++ /dev/null @@ -1,100 +0,0 @@ -// 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 deleted file mode 100644 index fe2790eb..00000000 --- a/components/account/ProfileEditor.tsx +++ /dev/null @@ -1,161 +0,0 @@ -// 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 c0ddfa01..1fb79fb5 100644 --- a/middleware.ts +++ b/middleware.ts @@ -18,7 +18,10 @@ 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[]) { @@ -30,7 +33,8 @@ 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; } @@ -40,7 +44,9 @@ 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; @@ -75,7 +81,8 @@ 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); } @@ -88,7 +95,8 @@ 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) { @@ -240,5 +248,7 @@ 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).*)", + ], }; diff --git a/tsconfig.json b/tsconfig.json index 37c2173d..ab0c0f42 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,10 @@ { "compilerOptions": { - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": false, @@ -9,16 +13,22 @@ "incremental": true, "esModuleInterop": true, "module": "esnext", - "moduleResolution": "bundler", + "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "baseUrl": ".", "paths": { - "@/*": ["./*"] + "@/*": [ + "./*" + ] }, - "plugins": [{ "name": "next" }], - "target": "ES2022" + "plugins": [ + { + "name": "next" + } + ], + "target": "ES2017" }, "include": [ "next-env.d.ts", @@ -26,5 +36,7 @@ "**/*.ts", "**/*.tsx" ], - "exclude": ["node_modules"] -} + "exclude": [ + "node_modules" + ] +} \ No newline at end of file