From 787de00274cdfbab7fa7c1a98ca2115123a04387 Mon Sep 17 00:00:00 2001 From: makearmy Date: Tue, 30 Sep 2025 00:56:23 -0400 Subject: [PATCH] basic user accounts + --- .env.local | 3 + app/api/account/avatar/route.ts | 60 +++++++++ app/api/account/password/route.ts | 41 ++++++ app/api/account/route.ts | 48 +++++++ app/portal/account/AccountClient.tsx | 183 +++++++++++++++++++++++++++ app/portal/account/page.tsx | 46 ++++++- 6 files changed, 374 insertions(+), 7 deletions(-) create mode 100644 app/api/account/avatar/route.ts create mode 100644 app/api/account/password/route.ts create mode 100644 app/api/account/route.ts create mode 100644 app/portal/account/AccountClient.tsx diff --git a/.env.local b/.env.local index 87dd8a40..d28b125a 100644 --- a/.env.local +++ b/.env.local @@ -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 diff --git a/app/api/account/avatar/route.ts b/app/api/account/avatar/route.ts new file mode 100644 index 00000000..9b647fe7 --- /dev/null +++ b/app/api/account/avatar/route.ts @@ -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); + } +} diff --git a/app/api/account/password/route.ts b/app/api/account/password/route.ts new file mode 100644 index 00000000..2cfb90a9 --- /dev/null +++ b/app/api/account/password/route.ts @@ -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); + } +} diff --git a/app/api/account/route.ts b/app/api/account/route.ts new file mode 100644 index 00000000..b97d7941 --- /dev/null +++ b/app/api/account/route.ts @@ -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(`/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 = {}; + 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); + } +} diff --git a/app/portal/account/AccountClient.tsx b/app/portal/account/AccountClient.tsx new file mode 100644 index 00000000..e772787d --- /dev/null +++ b/app/portal/account/AccountClient.tsx @@ -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(initialUser); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState(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(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) { + 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 ( +
+
+

Account

+ +
+ +
+ +
+

Usernames can’t be changed.

+
+ +
+
+
+ + setFirst(e.target.value)} /> +
+
+ + setLast(e.target.value)} /> +
+
+
+ + setEmail(e.target.value)} /> +
+
+ + setLocation(e.target.value)} /> +
+ +
+
+ {avatarThumb ? avatar : null} +
+
+ + + {uploading ?
Uploading…
: null} +
+
+ +
+ + {msg ? {msg} : null} +
+
+
+ +
+

Change Password

+
+
+ + setPwCurr(e.target.value)} /> +
+
+ + setPwNew(e.target.value)} /> +
+
+ + {pwMsg ? {pwMsg} : null} +
+
+

+ If this fails, enable password updates for your role or use the email reset flow. +

+
+
+ ); +} diff --git a/app/portal/account/page.tsx b/app/portal/account/page.tsx index c7fc0c04..09696b6f 100644 --- a/app/portal/account/page.tsx +++ b/app/portal/account/page.tsx @@ -1,9 +1,41 @@ // app/portal/account/page.tsx -export default function AccountPage() { - return ( -
-

Account

-

WIP: profile, tokens, preferences.

-
- ); +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 ; }