account management upgrades
This commit is contained in:
parent
94de501a49
commit
86fdd403b0
8 changed files with 439 additions and 46 deletions
|
|
@ -6,6 +6,7 @@ import { requireBearer } from "@/app/api/_lib/auth";
|
||||||
|
|
||||||
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
|
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
|
||||||
const bad = (m: string, c = 400) => NextResponse.json({ error: m }, { status: c });
|
const bad = (m: string, c = 400) => NextResponse.json({ error: m }, { status: c });
|
||||||
|
const secure = process.env.NODE_ENV === "production";
|
||||||
|
|
||||||
/** GET: current user's profile */
|
/** GET: current user's profile */
|
||||||
export async function GET(req: Request) {
|
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) {
|
export async function PATCH(req: Request) {
|
||||||
try {
|
try {
|
||||||
const bearer = requireBearer(req);
|
const bearer = requireBearer(req);
|
||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({} as Record<string, unknown>));
|
||||||
|
|
||||||
|
// 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<string, any> = {};
|
const payload: Record<string, any> = {};
|
||||||
if (typeof body.first_name === "string") payload.first_name = body.first_name.trim();
|
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 (typeof body.last_name === "string") payload.last_name = body.last_name.trim();
|
||||||
if ("email" in body) {
|
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
|
payload.email = e ? e : null; // optional; blank clears
|
||||||
}
|
}
|
||||||
if (typeof body.location === "string") payload.location = body.location.trim();
|
if (typeof body.location === "string") payload.location = body.location.trim();
|
||||||
if (typeof body.avatar === "string") payload.avatar = body.avatar; // file id
|
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");
|
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 });
|
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 });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const status = e?.status === 401 ? 401 : e?.status || 500;
|
const status = e?.status === 401 ? 401 : e?.status || 500;
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,14 @@ export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await req.json().catch(() => ({} as any));
|
const body = await req.json().catch(() => ({} as any));
|
||||||
const identifier = String(body?.identifier ?? "").trim();
|
const identifier = String(body?.identifier ?? "").trim();
|
||||||
const password = String(body?.password ?? "").trim();
|
const password = String(body?.password ?? "").trim();
|
||||||
|
|
||||||
if (!identifier || !password) {
|
if (!identifier || !password) {
|
||||||
return NextResponse.json({ error: "Missing credentials" }, { status: 400 });
|
return NextResponse.json({ error: "Missing credentials" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve identifier -> email (username allowed)
|
// Resolve identifier -> email (username or email accepted)
|
||||||
let email = identifier.includes("@") ? identifier : await emailForUsername(identifier);
|
const email = identifier.includes("@") ? identifier : await emailForUsername(identifier);
|
||||||
if (!email) return NextResponse.json({ error: "User not found" }, { status: 404 });
|
if (!email) return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
|
||||||
const auth = await loginDirectus(email, password);
|
const auth = await loginDirectus(email, password);
|
||||||
|
|
@ -30,7 +30,8 @@ export async function POST(req: NextRequest) {
|
||||||
const res = NextResponse.json({ ok: true });
|
const res = NextResponse.json({ ok: true });
|
||||||
|
|
||||||
// Refresh the access token cookie
|
// 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({
|
res.cookies.set({
|
||||||
name: "ma_at",
|
name: "ma_at",
|
||||||
value: access,
|
value: access,
|
||||||
|
|
@ -41,7 +42,7 @@ export async function POST(req: NextRequest) {
|
||||||
maxAge,
|
maxAge,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Short-lived client-visible flag: “recently authenticated”
|
// Short-lived client-visible flag: “recently authenticated” (5 minutes)
|
||||||
res.cookies.set({
|
res.cookies.set({
|
||||||
name: "ma_ra",
|
name: "ma_ra",
|
||||||
value: "1",
|
value: "1",
|
||||||
|
|
@ -49,7 +50,7 @@ export async function POST(req: NextRequest) {
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
secure,
|
secure,
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: 5 * 60, // 5 minutes
|
maxAge: 5 * 60,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
// app/portal/account/AccountPanel.tsx
|
// app/portal/account/AccountPanel.tsx
|
||||||
"use client";
|
"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 = {
|
type Me = {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
|
@ -10,7 +14,7 @@ type Me = {
|
||||||
last_name?: string | null;
|
last_name?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
location?: string | null;
|
location?: string | null;
|
||||||
avatar?: { id: string; filename_download: string } | null;
|
avatar?: Avatar;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AccountPanel() {
|
export default function AccountPanel() {
|
||||||
|
|
@ -18,20 +22,34 @@ export default function AccountPanel() {
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
setErr(null);
|
setErr(null);
|
||||||
const r = await fetch("/api/account", {
|
await refetchMe();
|
||||||
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 {
|
} finally {
|
||||||
if (alive) setLoading(false);
|
if (alive) setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -39,16 +57,13 @@ export default function AccountPanel() {
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [refetchMe]);
|
||||||
|
|
||||||
if (loading) return <div className="rounded-md border p-6 text-sm opacity-70">Loading…</div>;
|
if (loading) return <div className="rounded-md border p-6 text-sm opacity-70">Loading…</div>;
|
||||||
if (err) return <div className="rounded-md border p-6 text-red-600">Error: {err}</div>;
|
if (err) return <div className="rounded-md border p-6 text-red-600">Error: {err}</div>;
|
||||||
if (!me) return null;
|
if (!me) return null;
|
||||||
|
|
||||||
const avatarUrl =
|
const avatarUrl = me.avatar?.id ? `${API_BASE}/assets/${me.avatar.id}` : null;
|
||||||
me.avatar?.id
|
|
||||||
? `${process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/$/, "")}/assets/${me.avatar.id}`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -64,9 +79,7 @@ export default function AccountPanel() {
|
||||||
<span className="text-xs opacity-60">No Avatar</span>
|
<span className="text-xs opacity-60">No Avatar</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">Usernames can’t be changed.</div>
|
||||||
Usernames can’t be changed.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid sm:grid-cols-2 gap-3 text-sm">
|
<div className="grid sm:grid-cols-2 gap-3 text-sm">
|
||||||
|
|
@ -93,7 +106,18 @@ export default function AccountPanel() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add your edit forms/buttons below; they should only trigger reauth on submit */}
|
{/* Editable sections */}
|
||||||
|
<AvatarUploader
|
||||||
|
avatarId={me.avatar?.id || null}
|
||||||
|
onUpdated={refetchMe}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProfileEditor
|
||||||
|
me={me}
|
||||||
|
onUpdated={refetchMe}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PasswordChange />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export default async function AccountPage() {
|
||||||
if (!at) {
|
if (!at) {
|
||||||
redirect(`/auth/sign-in?next=${encodeURIComponent("/portal/account")}`);
|
redirect(`/auth/sign-in?next=${encodeURIComponent("/portal/account")}`);
|
||||||
}
|
}
|
||||||
// No reauth gating here; the panel will fetch and render profile,
|
// No reauth gating here; the panel fetches and renders profile,
|
||||||
// and only ask for reauth when user submits sensitive changes.
|
// and only asks for reauth when the user submits sensitive changes.
|
||||||
return <AccountPanel />;
|
return <AccountPanel />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
86
components/account/AvatarUploader.tsx
Normal file
86
components/account/AvatarUploader.tsx
Normal file
|
|
@ -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<File | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [msg, setMsg] = useState<string | null>(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 (
|
||||||
|
<div className="rounded-md border p-4 space-y-3">
|
||||||
|
<h3 className="font-semibold">Avatar</h3>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="h-16 w-16 rounded-full overflow-hidden border bg-muted flex items-center justify-center">
|
||||||
|
{currentUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={currentUrl} alt="Avatar" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs opacity-60">No Avatar</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="text-sm"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => setFile(e.currentTarget.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={onUpload}
|
||||||
|
disabled={busy || !file}
|
||||||
|
className="rounded-md bg-black text-white px-3 py-2 text-sm disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy ? "Uploading…" : "Upload"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{msg && <div className="text-sm opacity-80">{msg}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
components/account/PasswordChange.tsx
Normal file
100
components/account/PasswordChange.tsx
Normal file
|
|
@ -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<string | null>(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 (
|
||||||
|
<div id="security" className="rounded-md border p-4 space-y-3">
|
||||||
|
<h3 className="font-semibold">Change Password</h3>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 gap-3 text-sm">
|
||||||
|
<label className="grid gap-1 sm:col-span-2">
|
||||||
|
<span className="opacity-60">Current Password</span>
|
||||||
|
<input
|
||||||
|
className="rounded-md border px-2 py-1"
|
||||||
|
type="password"
|
||||||
|
value={current}
|
||||||
|
onChange={(e) => setCurrent(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-1">
|
||||||
|
<span className="opacity-60">New Password</span>
|
||||||
|
<input
|
||||||
|
className="rounded-md border px-2 py-1"
|
||||||
|
type="password"
|
||||||
|
value={next}
|
||||||
|
onChange={(e) => setNext(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-1">
|
||||||
|
<span className="opacity-60">Confirm New Password</span>
|
||||||
|
<input
|
||||||
|
className="rounded-md border px-2 py-1"
|
||||||
|
type="password"
|
||||||
|
value={next2}
|
||||||
|
onChange={(e) => setNext2(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={busy}
|
||||||
|
className="rounded-md bg-black text-white px-3 py-2 text-sm disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy ? "Saving…" : "Update Password"}
|
||||||
|
</button>
|
||||||
|
{msg && <div className="text-sm opacity-80">{msg}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
components/account/ProfileEditor.tsx
Normal file
161
components/account/ProfileEditor.tsx
Normal file
|
|
@ -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<Me | null>(meProp ?? null);
|
||||||
|
const [first_name, setFirst] = useState("");
|
||||||
|
const [last_name, setLast] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [location, setLocation] = useState("");
|
||||||
|
const [msg, setMsg] = useState<string | null>(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<string, any> = {
|
||||||
|
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 <div className="rounded-md border p-4 text-sm opacity-70">Loading editor…</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border p-4 space-y-3">
|
||||||
|
<h3 className="font-semibold">Edit Profile</h3>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 gap-3 text-sm">
|
||||||
|
<label className="grid gap-1">
|
||||||
|
<span className="opacity-60">First Name</span>
|
||||||
|
<input
|
||||||
|
className="rounded-md border px-2 py-1"
|
||||||
|
value={first_name}
|
||||||
|
onChange={(e) => setFirst(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-1">
|
||||||
|
<span className="opacity-60">Last Name</span>
|
||||||
|
<input
|
||||||
|
className="rounded-md border px-2 py-1"
|
||||||
|
value={last_name}
|
||||||
|
onChange={(e) => setLast(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-1 sm:col-span-2">
|
||||||
|
<span className="opacity-60">Email (reauth required)</span>
|
||||||
|
<input
|
||||||
|
className="rounded-md border px-2 py-1"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-1 sm:col-span-2">
|
||||||
|
<span className="opacity-60">Location</span>
|
||||||
|
<input
|
||||||
|
className="rounded-md border px-2 py-1"
|
||||||
|
value={location}
|
||||||
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
|
placeholder="City, Country"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={busy}
|
||||||
|
className="rounded-md bg-black text-white px-3 py-2 text-sm disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy ? "Saving…" : "Save Changes"}
|
||||||
|
</button>
|
||||||
|
{msg && <div className="text-sm opacity-80">{msg}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -18,10 +18,7 @@ import { NextResponse, NextRequest } from "next/server";
|
||||||
|
|
||||||
/** Directus base (used to remotely validate the token after restarts). */
|
/** Directus base (used to remotely validate the token after restarts). */
|
||||||
const DIRECTUS =
|
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? */
|
/** Helper: does the path start with any prefix in a list? */
|
||||||
function startsWithAny(pathname: string, prefixes: string[]) {
|
function startsWithAny(pathname: string, prefixes: string[]) {
|
||||||
|
|
@ -33,8 +30,7 @@ import { NextResponse, NextRequest } from "next/server";
|
||||||
const dest = new URL(req.url);
|
const dest = new URL(req.url);
|
||||||
dest.pathname = mapped.pathname;
|
dest.pathname = mapped.pathname;
|
||||||
if (mapped.query) {
|
if (mapped.query) {
|
||||||
for (const [k, v] of Object.entries(mapped.query))
|
for (const [k, v] of Object.entries(mapped.query)) dest.searchParams.set(k, v);
|
||||||
dest.searchParams.set(k, v);
|
|
||||||
}
|
}
|
||||||
return dest.href === req.url;
|
return dest.href === req.url;
|
||||||
}
|
}
|
||||||
|
|
@ -44,9 +40,7 @@ import { NextResponse, NextRequest } from "next/server";
|
||||||
try {
|
try {
|
||||||
const [, payload] = token.split(".");
|
const [, payload] = token.split(".");
|
||||||
if (!payload) return null;
|
if (!payload) return null;
|
||||||
const json = JSON.parse(
|
const json = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
|
||||||
atob(payload.replace(/-/g, "+").replace(/_/g, "/"))
|
|
||||||
);
|
|
||||||
return typeof json.exp === "number" ? json.exp : null;
|
return typeof json.exp === "number" ? json.exp : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -81,8 +75,7 @@ import { NextResponse, NextRequest } from "next/server";
|
||||||
if (mapped && !isSameUrl(req, mapped)) {
|
if (mapped && !isSameUrl(req, mapped)) {
|
||||||
url.pathname = mapped.pathname;
|
url.pathname = mapped.pathname;
|
||||||
if (mapped.query) {
|
if (mapped.query) {
|
||||||
for (const [k, v] of Object.entries(mapped.query))
|
for (const [k, v] of Object.entries(mapped.query)) url.searchParams.set(k, v);
|
||||||
url.searchParams.set(k, v);
|
|
||||||
}
|
}
|
||||||
return NextResponse.redirect(url);
|
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
|
// Allow explicit reauth flow even if a (possibly stale) token cookie exists
|
||||||
const forceAuth =
|
const forceAuth =
|
||||||
isAuthRoute &&
|
isAuthRoute &&
|
||||||
(url.searchParams.get("reauth") === "1" ||
|
(url.searchParams.get("reauth") === "1" || url.searchParams.get("force") === "1");
|
||||||
url.searchParams.get("force") === "1");
|
|
||||||
|
|
||||||
// If unauthenticated and the route is protected, send to sign-in (with next + reauth)
|
// If unauthenticated and the route is protected, send to sign-in (with next + reauth)
|
||||||
if (!token && isProtected) {
|
if (!token && isProtected) {
|
||||||
|
|
@ -248,7 +240,5 @@ import { NextResponse, NextRequest } from "next/server";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: ["/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|images|static).*)"],
|
||||||
"/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|images|static).*)",
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue