account page render fix
This commit is contained in:
parent
996ebe4757
commit
6ea6080b11
4 changed files with 161 additions and 223 deletions
|
|
@ -1,15 +1,21 @@
|
|||
// app/auth/sign-in/page.tsx
|
||||
import { Suspense } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import SignIn from "./sign-in";
|
||||
|
||||
// Ensure this page renders at runtime (avoids SSG trying to pre-render a client-only page)
|
||||
export const dynamic = "force-dynamic";
|
||||
export default async function SignInPage(
|
||||
props: { searchParams: Promise<Record<string, string | string[] | undefined>> }
|
||||
) {
|
||||
const at = (await cookies()).get("ma_at")?.value;
|
||||
if (at) redirect("/portal");
|
||||
|
||||
export default function SignInPage() {
|
||||
// Wrap the client component (which uses useSearchParams) in Suspense
|
||||
return (
|
||||
<Suspense fallback={<div className="p-6">Loading…</div>}>
|
||||
<SignIn />
|
||||
</Suspense>
|
||||
);
|
||||
const sp = await props.searchParams;
|
||||
const nextParam = Array.isArray(sp.next) ? sp.next[0] : sp.next;
|
||||
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 reauth = reauthParam === "1" || forceParam === "1";
|
||||
|
||||
return <SignIn nextPath={nextPath} reauth={reauth} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,130 +2,114 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Props = { nextPath?: string };
|
||||
type Props = { nextPath?: string; reauth?: boolean };
|
||||
|
||||
export default function SignIn({ nextPath = "/portal" }: Props) {
|
||||
export default function SignIn({ nextPath = "/portal", reauth = false }: Props) {
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
|
||||
// Respect reauth/force flags from query
|
||||
const reauth = sp.get("reauth") === "1" || sp.get("force") === "1";
|
||||
const next = sp.get("next") || nextPath;
|
||||
|
||||
const [identifier, setIdentifier] = useState(""); // email OR username
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShow] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ identifier, password }),
|
||||
});
|
||||
|
||||
const txt = await res.text();
|
||||
let j: any = null;
|
||||
try { j = txt ? JSON.parse(txt) : null; } catch {}
|
||||
|
||||
if (!res.ok) {
|
||||
const message = j?.error || j?.message || `Sign-in failed (${res.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
// Success → go to intended destination (account page in reauth flow)
|
||||
router.replace(next);
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Unable to sign in.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const onSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ identifier, password }),
|
||||
});
|
||||
const txt = await res.text();
|
||||
let j: any = null; try { j = txt ? JSON.parse(txt) : null; } catch {}
|
||||
if (!res.ok) {
|
||||
throw new Error(j?.error || j?.message || `Sign-in failed (${res.status})`);
|
||||
}
|
||||
},
|
||||
[identifier, password, next, router]
|
||||
);
|
||||
// Land where caller requested
|
||||
router.replace(nextPath);
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Unable to sign in.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [identifier, password, nextPath, router]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md rounded-lg border p-6">
|
||||
{reauth && (
|
||||
<div className="mb-4 rounded-md border p-3 bg-card">
|
||||
<div className="font-medium">Please sign in again</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
For security, we need to confirm it’s you before accessing account settings.
|
||||
<div className="mx-auto max-w-md rounded-lg border p-6 space-y-4">
|
||||
<div>
|
||||
<h1 className="mb-1 text-2xl font-semibold">
|
||||
{reauth ? "Re-authenticate" : "Sign In"}
|
||||
</h1>
|
||||
<p className="text-sm opacity-70">
|
||||
{reauth
|
||||
? "Please sign in again to continue."
|
||||
: <>Use your email <em>or</em> username with your password.</>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Email or Username</label>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
placeholder="you@example.com or your-handle"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 className="mb-1 text-2xl font-semibold">Sign In</h1>
|
||||
<p className="mb-6 text-sm opacity-70">
|
||||
Use your email <em>or</em> username with your password.
|
||||
</p>
|
||||
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Email or Username</label>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
placeholder="you@example.com or your-handle"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Password</label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs opacity-70 hover:opacity-100"
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
>
|
||||
{showPassword ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{err}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Password</label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs opacity-70 hover:opacity-100"
|
||||
onClick={() => setShow((s) => !s)}
|
||||
>
|
||||
{showPassword ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-black px-3 py-2 text-white disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Signing in…" : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
{err && (
|
||||
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
<span className="opacity-70">New here?</span>{" "}
|
||||
<a className="underline" href={"/auth/sign-up"}>Create an account</a>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-black px-3 py-2 text-white disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Signing in…" : reauth ? "Re-authenticate" : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{!reauth && (
|
||||
<div className="mt-2 text-center text-sm">
|
||||
<span className="opacity-70">New here?</span>{" "}
|
||||
<a className="underline" href="/auth/sign-up">Create an account</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
// app/portal/account/AccountClient.tsx
|
||||
// app/portal/account/AccountPanel.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
type Me = {
|
||||
id: string;
|
||||
|
|
@ -11,112 +10,53 @@ type Me = {
|
|||
last_name?: string | null;
|
||||
email?: string | null;
|
||||
location?: string | null;
|
||||
avatar?: { id: string; filename_download?: string } | null;
|
||||
avatar?: { id: string; filename_download: string } | null;
|
||||
};
|
||||
|
||||
export default function AccountClient() {
|
||||
export default function AccountPanel() {
|
||||
const [me, setMe] = useState<Me | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [needReauth, setNeedReauth] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setNeedReauth(false);
|
||||
try {
|
||||
const res = await fetch("/api/account", {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
setNeedReauth(true);
|
||||
setMe(null);
|
||||
return;
|
||||
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");
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j?.error || `Failed: ${res.status}`);
|
||||
}
|
||||
const j = await res.json();
|
||||
setMe(j as Me);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load account");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-6 text-sm opacity-70">Loading account…</div>;
|
||||
}
|
||||
|
||||
if (needReauth) {
|
||||
return (
|
||||
<div className="p-6 max-w-xl">
|
||||
<div className="rounded-md border p-4">
|
||||
<h2 className="text-lg font-semibold mb-2">Confirm it’s you</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
For security, please sign in again before changing account details.
|
||||
</p>
|
||||
<Link
|
||||
href={`/auth/sign-in?reauth=1&force=1&next=${encodeURIComponent("/portal/account")}`}
|
||||
className="inline-block px-3 py-2 rounded-md border bg-accent text-background"
|
||||
>
|
||||
Re-authenticate
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6 max-w-xl">
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="text-red-600 mb-2">Error: {error}</div>
|
||||
<button
|
||||
onClick={load}
|
||||
className="px-3 py-2 rounded-md border hover:bg-muted text-sm"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
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 (!me) return null;
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl space-y-6">
|
||||
{/* View-only header */}
|
||||
<section className="rounded-md border p-4">
|
||||
<h1 className="text-xl font-semibold mb-2">Account</h1>
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">Username:</span> {me.username}{" "}
|
||||
<span className="text-muted-foreground">(usernames can’t be changed)</span>
|
||||
</p>
|
||||
</section>
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-md border p-4">
|
||||
<h2 className="text-lg font-semibold mb-2">Account</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-3 text-sm">
|
||||
<div><div className="opacity-60">Username</div><div className="font-medium">{me.username}</div></div>
|
||||
<div><div className="opacity-60">First Name</div><div>{me.first_name || "—"}</div></div>
|
||||
<div><div className="opacity-60">Last Name</div><div>{me.last_name || "—"}</div></div>
|
||||
<div><div className="opacity-60">Email</div><div>{me.email || "—"}</div></div>
|
||||
<div><div className="opacity-60">Location</div><div>{me.location || "—"}</div></div>
|
||||
</div>
|
||||
<p className="mt-3 text-xs opacity-70">Usernames can’t be changed.</p>
|
||||
</div>
|
||||
|
||||
{/* Your existing edit forms can sit here, wired to:
|
||||
- PATCH /api/account for first_name, last_name, email (optional), location
|
||||
- POST /api/account/password for password change (asks current password in the form)
|
||||
- POST /api/account/avatar for avatar upload (to your folder)
|
||||
None of these fire automatically; only on submit. */}
|
||||
|
||||
{/* Example placeholder showing current profile fields */}
|
||||
<section className="rounded-md border p-4">
|
||||
<h2 className="text-lg font-semibold mb-3">Profile</h2>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div><span className="text-muted-foreground">First name:</span> {me.first_name || "—"}</div>
|
||||
<div><span className="text-muted-foreground">Last name:</span> {me.last_name || "—"}</div>
|
||||
<div><span className="text-muted-foreground">Email:</span> {me.email || "—"}</div>
|
||||
<div><span className="text-muted-foreground">Location:</span> {me.location || "—"}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{/* Place your edit forms here; they should only call the update APIs on submit,
|
||||
never during initial render. For password/avatar we can prompt for password
|
||||
inline or send to /auth/sign-in?reauth=1&next=/portal/account#security. */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
// app/portal/account/page.tsx
|
||||
export const dynamic = "force-dynamic"; // don't cache this page
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import AccountPanel from "./AccountPanel";
|
||||
|
||||
import AccountClient from "./AccountClient";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function Page() {
|
||||
return <AccountClient />;
|
||||
export default async function AccountPage() {
|
||||
const at = (await cookies()).get("ma_at")?.value;
|
||||
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.
|
||||
return <AccountPanel />;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue