basic user accounts +

This commit is contained in:
makearmy 2025-09-30 00:56:23 -04:00
parent a7d83e625b
commit 787de00274
6 changed files with 374 additions and 7 deletions

View file

@ -4,3 +4,6 @@ NEXT_PUBLIC_API_BASE_URL=https://forms.lasereverything.net
# Server-side (used by API routes)
DIRECTUS_URL=https://forms.lasereverything.net
DIRECTUS_TOKEN_ADMIN_REGISTER=l_QqNXKpi--Dt-hHDncHyBX0eiHNYZr7
# Image Folders
DIRECTUS_AVATAR_FOLDER_ID=b8ddddf8-3ee3-4380-b27e-c7a5f01deef1

View file

@ -0,0 +1,60 @@
// app/api/account/avatar/route.ts
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { requireBearer } from "@/app/api/_lib/auth";
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
const AVATAR_FOLDER_ID = process.env.DIRECTUS_AVATAR_FOLDER_ID || "";
function bad(msg: string, code = 400) {
return NextResponse.json({ error: msg }, { status: code });
}
export async function POST(req: Request) {
try {
if (!AVATAR_FOLDER_ID) {
return bad("Server misconfiguration: DIRECTUS_AVATAR_FOLDER_ID is not set", 500);
}
const bearer = requireBearer(req);
const form = await req.formData();
const file = form.get("file");
if (!(file instanceof Blob)) return bad("Missing file");
// Upload directly into the configured avatars folder
const up = new FormData();
up.set("file", file, (file as any).name || "avatar.bin");
up.set("folder", AVATAR_FOLDER_ID);
const r1 = await fetch(`${API}/files`, {
method: "POST",
headers: { Authorization: bearer },
body: up,
});
const j1 = await r1.json().catch(() => ({}));
if (!r1.ok) {
const msg = j1?.errors?.[0]?.message || "Upload failed";
return bad(msg, r1.status);
}
const fileId: string = j1?.data?.id ?? j1?.id;
// Link file to the current user
const r2 = await fetch(`${API}/users/me`, {
method: "PATCH",
headers: { Authorization: bearer, "Content-Type": "application/json" },
body: JSON.stringify({ avatar: fileId }),
});
const j2 = await r2.json().catch(() => ({}));
if (!r2.ok) {
const msg = j2?.errors?.[0]?.message || "Link avatar failed";
return bad(msg, r2.status);
}
// Respond with the updated avatar reference
return NextResponse.json({ ok: true, avatar: j2?.data?.avatar ?? { id: fileId } });
} catch (e: any) {
return bad(e?.message || "Upload error", e?.status || 500);
}
}

View file

@ -0,0 +1,41 @@
// app/api/account/password/route.ts
import { NextResponse } from "next/server";
import { requireBearer } from "@/app/api/_lib/auth";
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
function bad(msg: string, code = 400) {
return NextResponse.json({ error: msg }, { status: code });
}
/**
* This uses the simplest approach: PATCH /users/me { password: NEW }.
* It requires your role to have Update permission on `directus_users` with
* filter id = $CURRENT_USER and field permission for `password`.
* If your Directus is configured to use a dedicated password endpoint instead,
* swap the implementation accordingly.
*/
export async function POST(req: Request) {
try {
const bearer = requireBearer(req);
const { current_password, new_password } = await req.json().catch(() => ({}));
if (!new_password) return bad("Missing new_password");
// Optional: you can verify current_password server-side by attempting a login
// to your auth endpoint; omitted here to keep it simple.
const r = await fetch(`${API}/users/me`, {
method: "PATCH",
headers: { Authorization: bearer, "Content-Type": "application/json" },
body: JSON.stringify({ password: new_password }),
});
const j = await r.json().catch(() => ({}));
if (!r.ok) {
const msg = j?.errors?.[0]?.message || "Password update failed";
return bad(msg, r.status);
}
return NextResponse.json({ ok: true });
} catch (e: any) {
return bad(e?.message || "Failed to change password", e?.status || 500);
}
}

48
app/api/account/route.ts Normal file
View file

@ -0,0 +1,48 @@
// app/api/account/route.ts
import { NextResponse } from "next/server";
import { dxGET } from "@/lib/directus";
import { requireBearer } from "@/app/api/_lib/auth";
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
function bad(msg: string, code = 400) {
return NextResponse.json({ error: msg }, { status: code });
}
export async function GET(req: Request) {
try {
const bearer = requireBearer(req);
const fields = encodeURIComponent([
"id","username","first_name","last_name","email","location","avatar.id","avatar.filename_download","avatar.title"
].join(","));
const me = await dxGET<any>(`/users/me?fields=${fields}`, bearer);
return NextResponse.json(me?.data ?? me ?? {});
} catch (e: any) {
return bad(e?.message || "Failed to load account", e?.status || 500);
}
}
export async function PATCH(req: Request) {
try {
const bearer = requireBearer(req);
const body = await req.json().catch(() => ({}));
const payload: Record<string, any> = {};
for (const k of ["first_name","last_name","email","location"]) {
if (k in body) payload[k] = body[k] ?? null;
}
if (!Object.keys(payload).length) return bad("No changes");
const res = await fetch(`${API}/users/me`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: bearer },
body: JSON.stringify(payload),
});
const j = await res.json().catch(() => ({}));
if (!res.ok) return bad(j?.errors?.[0]?.message || "Update failed", res.status);
return NextResponse.json(j?.data ?? j ?? {});
} catch (e: any) {
return bad(e?.message || "Failed to update account", e?.status || 500);
}
}

View file

@ -0,0 +1,183 @@
// app/portal/account/AccountClient.tsx
"use client";
import { 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; title?: string } | string | null;
};
export default function AccountClient({ initialUser }: { initialUser: Me }) {
const [user, setUser] = useState<Me>(initialUser);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [firstName, setFirst] = useState(user.first_name ?? "");
const [lastName, setLast] = useState(user.last_name ?? "");
const [email, setEmail] = useState(user.email ?? "");
const [location, setLocation] = useState(user.location ?? "");
const [pwCurr, setPwCurr] = useState("");
const [pwNew, setPwNew] = useState("");
const [pwMsg, setPwMsg] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const avatarThumb = (() => {
// Directus file thumbnails: /assets/{id}?download=... if you expose assets
const id = typeof user.avatar === "object" ? user.avatar?.id : user.avatar;
if (!id) return null;
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
return `${base}/assets/${id}?width=96&height=96&fit=cover`;
})();
async function saveProfile(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setMsg(null);
try {
const res = await fetch("/api/account", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
first_name: firstName.trim(),
last_name: lastName.trim(),
email: email.trim(),
location: location.trim(),
}),
});
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j?.error || "Update failed");
setUser((u) => ({ ...u, ...j }));
setMsg("Profile updated.");
} catch (err: any) {
setMsg(err?.message || "Failed to update.");
} finally {
setSaving(false);
}
}
async function onAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setMsg(null);
try {
const fd = new FormData();
fd.append("file", file, file.name);
const res = await fetch("/api/account/avatar", { method: "POST", body: fd, credentials: "include" });
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j?.error || "Upload failed");
// Refresh our user record
setUser((u) => ({ ...u, avatar: j.avatar }));
setMsg("Avatar updated.");
} catch (err: any) {
setMsg(err?.message || "Failed to upload avatar.");
} finally {
setUploading(false);
e.currentTarget.value = "";
}
}
async function changePassword(e: React.FormEvent) {
e.preventDefault();
setPwMsg(null);
try {
const res = await fetch("/api/account/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ current_password: pwCurr, new_password: pwNew }),
});
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j?.error || "Password change failed");
setPwMsg("Password updated.");
setPwCurr("");
setPwNew("");
} catch (err: any) {
setPwMsg(err?.message || "Failed to update password.");
}
}
return (
<div className="max-w-2xl space-y-8">
<section className="rounded-lg border p-4">
<h2 className="text-lg font-semibold mb-3">Account</h2>
<div className="mb-4">
<label className="text-sm text-muted-foreground">Username (read-only)</label>
<div className="mt-1">
<input className="w-full border rounded px-2 py-1 bg-muted" value={user.username} readOnly />
</div>
<p className="text-xs text-muted-foreground mt-1">Usernames cant be changed.</p>
</div>
<form onSubmit={saveProfile} className="grid gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="text-sm">First name</label>
<input className="w-full border rounded px-2 py-1" value={firstName} onChange={(e) => setFirst(e.target.value)} />
</div>
<div>
<label className="text-sm">Last name</label>
<input className="w-full border rounded px-2 py-1" value={lastName} onChange={(e) => setLast(e.target.value)} />
</div>
</div>
<div>
<label className="text-sm">Email</label>
<input type="email" className="w-full border rounded px-2 py-1" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div>
<label className="text-sm">Location</label>
<input className="w-full border rounded px-2 py-1" value={location} onChange={(e) => setLocation(e.target.value)} />
</div>
<div className="flex items-center gap-4 mt-2">
<div className="w-24 h-24 rounded-full overflow-hidden border bg-muted">
{avatarThumb ? <img src={avatarThumb} alt="avatar" className="w-full h-full object-cover" /> : null}
</div>
<div>
<label className="text-sm block mb-1">Avatar</label>
<input type="file" accept="image/*" onChange={onAvatarChange} disabled={uploading} />
{uploading ? <div className="text-xs opacity-70 mt-1">Uploading</div> : null}
</div>
</div>
<div className="flex items-center gap-2 mt-2">
<button disabled={saving} className="px-3 py-2 border rounded bg-accent text-background hover:opacity-90">
{saving ? "Saving…" : "Save changes"}
</button>
{msg ? <span className="text-sm">{msg}</span> : null}
</div>
</form>
</section>
<section className="rounded-lg border p-4">
<h3 className="text-lg font-semibold mb-3">Change Password</h3>
<form onSubmit={changePassword} className="grid gap-3 max-w-md">
<div>
<label className="text-sm">Current password</label>
<input type="password" className="w-full border rounded px-2 py-1" value={pwCurr} onChange={(e) => setPwCurr(e.target.value)} />
</div>
<div>
<label className="text-sm">New password</label>
<input type="password" className="w-full border rounded px-2 py-1" value={pwNew} onChange={(e) => setPwNew(e.target.value)} />
</div>
<div className="flex items-center gap-2">
<button className="px-3 py-2 border rounded hover:bg-muted">Update password</button>
{pwMsg ? <span className="text-sm">{pwMsg}</span> : null}
</div>
</form>
<p className="text-xs text-muted-foreground mt-2">
If this fails, enable password updates for your role or use the email reset flow.
</p>
</section>
</div>
);
}

View file

@ -1,9 +1,41 @@
// app/portal/account/page.tsx
export default function AccountPage() {
return (
<div className="rounded-lg border p-6">
<h2 className="text-xl font-semibold mb-2">Account</h2>
<p className="opacity-80">WIP: profile, tokens, preferences.</p>
</div>
);
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { dxGET } from "@/lib/directus";
import AccountClient from "./AccountClient";
export const dynamic = "force-dynamic";
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; title?: string } | string | null;
};
export default async function Page() {
const jar = await cookies();
const token = jar.get("ma_at")?.value;
if (!token) redirect("/auth/sign-in?next=/portal/account");
const bearer = `Bearer ${token}`;
const fields = encodeURIComponent([
"id",
"username",
"first_name",
"last_name",
"email",
"location",
"avatar.id",
"avatar.filename_download",
"avatar.title",
].join(","));
const me = await dxGET<{ data: Me }>(`/users/me?fields=${fields}`, bearer);
const user: Me = (me as any)?.data ?? me;
return <AccountClient initialUser={user} />;
}