From 226fcc8013d4ba5b318c247144eccb5add02fcc4 Mon Sep 17 00:00:00 2001 From: makearmy Date: Fri, 26 Sep 2025 17:29:47 -0400 Subject: [PATCH] directus permissions fixes and rig_type addition --- app/api/auth/login/route.ts | 85 +++--- app/my/rigs/page.tsx | 509 +++++++++++++++++++---------------- components/SignOutButton.tsx | 50 ++-- 3 files changed, 355 insertions(+), 289 deletions(-) diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 0b46ccd9..cc9d2450 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -3,68 +3,71 @@ import { NextRequest, NextResponse } from "next/server"; import { setAuthCookies } from "@/lib/auth-cookies"; const BASE = process.env.DIRECTUS_URL!; -if (!BASE) console.warn("[auth/login] Missing DIRECTUS_URL"); +const ADMIN_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_REGISTER || ""; -async function jsonSafe(res: Response) { - const text = await res.text(); - try { return { json: text ? JSON.parse(text) : null, text }; } - catch { return { json: null as any, text }; } +async function findEmailForIdentifier(identifier: string): Promise { + const id = (identifier || "").trim(); + if (!id) return null; + + // If it's an email, we're done. + if (id.includes("@")) return id; + + // Otherwise look up by username using the admin/registration token. + if (!ADMIN_TOKEN) return null; + + const res = await fetch( + `${BASE}/users?filter[username][_eq]=${encodeURIComponent(id)}&fields=id,email,username&limit=1`, + { headers: { Authorization: `Bearer ${ADMIN_TOKEN}`, Accept: "application/json" } } + ); + + const json: any = await res.json().catch(() => null); + return json?.data?.[0]?.email ?? null; } export async function POST(req: NextRequest) { try { - const body = await req.json().catch(() => ({})); - const identity: string = (body.identity || body.usernameOrEmail || "").trim(); - const password: string = String(body.password || ""); + const body = await req.json(); + const identifier = (body?.identifier ?? body?.email ?? "").trim(); + const password = body?.password ?? ""; - if (!identity || !password) { - return NextResponse.json({ error: "Missing identity or password" }, { status: 400 }); + if (!identifier || !password) { + return NextResponse.json({ error: "Missing credentials" }, { status: 400 }); } - // Directus login (username OR email works via "email" field for both) + const email = await findEmailForIdentifier(identifier); + if (!email) { + return NextResponse.json({ error: "Account not found" }, { status: 401 }); + } + + // Login to Directus const loginRes = await fetch(`${BASE}/auth/login`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, - body: JSON.stringify({ email: identity, password }), + body: JSON.stringify({ email, password }), }); - const { json: loginJson, text: loginText } = await jsonSafe(loginRes); + const loginJson: any = await loginRes.json().catch(() => null); if (!loginRes.ok) { - const msg = - loginJson?.errors?.[0]?.message || - loginJson?.message || - `Directus login failed: ${loginRes.status} ${loginRes.statusText}`; - return NextResponse.json({ error: msg }, { status: 401 }); + const msg = loginJson?.errors?.[0]?.message || loginRes.statusText; + return NextResponse.json({ error: msg }, { status: loginRes.status }); } - const access = loginJson?.data?.access_token || loginJson?.access_token; - const refresh = loginJson?.data?.refresh_token || loginJson?.refresh_token; - if (!access || !refresh) { - return NextResponse.json( - { error: `No tokens returned from Directus: ${loginText?.slice(0, 200) || ""}` }, - { status: 500 } - ); + const tokens = loginJson?.data ?? loginJson ?? {}; + const access = tokens.access_token; + const refresh = tokens.refresh_token; + if (!access) { + return NextResponse.json({ error: "Login failed (no token)" }, { status: 500 }); } - // Fetch user profile - const meRes = await fetch(`${BASE}/users/me`, { + // Fetch user profile for the client + const meRes = await fetch(`${BASE}/users/me?fields=id,email,username`, { headers: { Authorization: `Bearer ${access}`, Accept: "application/json" }, - cache: "no-store", }); - const { json: meJson } = await jsonSafe(meRes); - if (!meRes.ok) { - return NextResponse.json( - { error: meJson?.errors?.[0]?.message || "Failed to fetch user" }, - { status: 500 } - ); - } - const user = { - id: String(meJson?.data?.id ?? ""), - email: String(meJson?.data?.email ?? ""), - username: String(meJson?.data?.username ?? ""), - }; + const meJson: any = await meRes.json().catch(() => null); + const user = (meJson?.data ?? meJson) || {}; let res = NextResponse.json({ ok: true, user }); - res = setAuthCookies(res, { access_token: access, refresh_token: refresh }, user); + // Persist auth cookies expected by the rest of the app + res = setAuthCookies(res as any, { access_token: access, refresh_token: refresh } as any, user); return res; } catch (err: any) { return NextResponse.json({ error: err?.message || "Login error" }, { status: 500 }); diff --git a/app/my/rigs/page.tsx b/app/my/rigs/page.tsx index 1db1afbd..c4c43380 100644 --- a/app/my/rigs/page.tsx +++ b/app/my/rigs/page.tsx @@ -1,269 +1,326 @@ "use client"; +import * as React from "react"; import { useEffect, useMemo, useState } from "react"; -import { useForm, Controller } from "react-hook-form"; +import { useForm } from "react-hook-form"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; -import SignOutButton from "@/components/SignOutButton"; // ⟵ default import - import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectValue, +} from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; -import { Badge } from "@/components/ui/badge"; import { useToast } from "@/hooks/use-toast"; +import SignOutButton from "@/components/SignOutButton"; -type Opt = { id: string; label: string }; +type Option = { id: string; label: string }; -// --- schema (minimal; expand later) --- -const RigSchema = z.object({ - name: z.string().min(2, "Give your rig a short name"), - notes: z.string().optional(), - laser_source: z.string().min(1, "Laser source is required"), - laser_software: z.string().min(1, "Software is required"), - laser_scan_lens: z.string().optional().nullable(), - laser_focus_lens: z.string().optional().nullable(), - laser_scan_lens_apt: z.string().optional().nullable(), - laser_scan_lens_exp: z.string().optional().nullable(), +// ───────────────────────────────────────────────────────────── +// Rig Type → options mapping +// These mirror existing “settings_*” targets used by the options endpoints. +// ───────────────────────────────────────────────────────────── +const RIG_TYPES = [ + { value: "settings_fiber", label: "Fiber (Galvo)" }, + { value: "settings_uv", label: "UV (Galvo)" }, + { value: "settings_co2gal", label: "CO₂ Galvo" }, +{ value: "settings_co2gan", label: "CO₂ Gantry" }, +] as const; + +type RigType = (typeof RIG_TYPES)[number]["value"]; +const isGantry = (t: RigType) => t === "settings_co2gan"; + +// ───────────────────────────────────────────────────────────── +// Validation – conditionally require the right lens field +// ───────────────────────────────────────────────────────────── +const RigSchema = z +.object({ + name: z.string().min(1, "Name is required"), + rig_type: z.enum(RIG_TYPES.map((r) => r.value) as [RigType, ...RigType[]]), + laser_source: z.string().min(1, "Source is required"), + laser_software: z.string().min(1, "Software is required"), + // lens fields (one of these is required depending on rig_type) + laser_scan_lens: z.string().optional(), + laser_focus_lens: z.string().optional(), + // optional galvo bits + laser_scan_lens_apt: z.string().optional(), + laser_scan_lens_exp: z.string().optional(), + notes: z.string().optional(), +}) +.superRefine((val, ctx) => { + if (isGantry(val.rig_type)) { + if (!val.laser_focus_lens) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["laser_focus_lens"], message: "Focus lens is required for CO₂ Gantry" }); + } + // clear any galvo-only extras client-side + val.laser_scan_lens = undefined; + val.laser_scan_lens_apt = undefined; + val.laser_scan_lens_exp = undefined; + } else { + if (!val.laser_scan_lens) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["laser_scan_lens"], message: "Scan lens is required" }); + } + // clear the gantry-only field + val.laser_focus_lens = undefined; + } }); type RigForm = z.infer; -function useOptions(path: string) { - const [opts, setOpts] = useState([]); - const [loading, setLoading] = useState(false); - const [q, setQ] = useState(""); - - useEffect(() => { - let alive = true; - setLoading(true); - const url = `/api/options/${path}${path.includes("?") ? "&" : "?"}q=${encodeURIComponent(q)}`; - fetch(url, { cache: "no-store" }) - .then((r) => r.json()) - .then((j) => { if (alive) setOpts((j?.data as Opt[]) ?? []); }) - .finally(() => { if (alive) setLoading(false); }); - return () => { alive = false; }; - }, [path, q]); - - return { opts, loading, setQ }; +// ───────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────── +async function fetchOptions(url: string): Promise { + const res = await fetch(url); + if (!res.ok) return []; + const json = await res.json().catch(() => null); + return json?.data || []; } -function Select({ - label, value, onChange, options, placeholder = "—", loading, -}: { - label: string; - value?: string | null; - onChange: (v: string) => void; - options: Opt[]; - placeholder?: string; - loading?: boolean; -}) { - const [filter, setFilter] = useState(""); - const filtered = useMemo(() => { - if (!filter) return options; - const f = filter.toLowerCase(); - return options.filter((o) => o.label.toLowerCase().includes(f)); - }, [options, filter]); - - return ( -
- - setFilter(e.target.value)} - /> - -
- ); +// Map rig_type → /api/options target +function targetFor(type: RigType): string { + return type; } export default function MyRigsPage() { const { toast } = useToast(); - // options - const srcs = useOptions("laser_source?target=settings_fiber"); // any; user can mix later - const soft = useOptions("laser_software"); - const scan = useOptions("lens?target=settings_fiber"); // scan lens list - const focus = useOptions("lens?target=settings_co2gan"); // focus lens list (gantry) - - const apt = useOptions("laser_scan_lens_apt"); // aperture list (if exists) - const exp = useOptions("laser_scan_lens_exp"); // expander list (if exists) - - const { handleSubmit, control, reset, formState: { isSubmitting } } = useForm({ + const form = useForm({ resolver: zodResolver(RigSchema), - defaultValues: { - name: "", - notes: "", - laser_source: "", - laser_software: "", - laser_scan_lens: "", - laser_focus_lens: "", - laser_scan_lens_apt: "", - laser_scan_lens_exp: "", - }, + defaultValues: { + name: "", + rig_type: "settings_fiber", + laser_source: "", + laser_software: "", + laser_scan_lens: "", + laser_focus_lens: "", + laser_scan_lens_apt: "", + laser_scan_lens_exp: "", + notes: "", + }, }); - async function onSubmit(values: RigForm) { - try { - const res = await fetch("/api/my/rigs", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(values), - }); - const j = await res.json(); - if (!res.ok) throw new Error(j?.error || "Failed to save rig"); + const rigType = form.watch("rig_type"); - toast({ title: "Rig saved", description: "Your rig has been saved to your account." }); - reset(); - } catch (err: any) { - toast({ title: "Save failed", description: err?.message || "Unknown error", variant: "destructive" }); + const [sources, setSources] = useState([]); + const [software, setSoftware] = useState([]); + const [scanLens, setScanLens] = useState([]); + const [focusLens, setFocusLens] = useState([]); + const [apertures, setApertures] = useState([]); + const [expanders, setExpanders] = useState([]); + + // Load static options that don’t depend on rig_type + useEffect(() => { + fetchOptions("/api/options/laser_software").then(setSoftware); + }, []); + + // Load options that do depend on rig_type + useEffect(() => { + const target = targetFor(rigType); + // laser sources filtered by wavelength + fetchOptions(`/api/options/laser_source?target=${encodeURIComponent(target)}`).then(setSources); + + // lens: our /api/options/lens endpoint already returns: + // - SCAN lenses for galvo targets (with mm + F-number label) + // - FOCUS lenses for CO₂ gantry target (label=name) + fetchOptions(`/api/options/lens?target=${encodeURIComponent(target)}`).then((data) => { + if (isGantry(rigType)) { + setFocusLens(data); + setScanLens([]); + } else { + setScanLens(data); + setFocusLens([]); + } + }); + + // Optional galvo-only option lists (safe to fetch; we’ll hide in UI for gantry) + fetchOptions("/api/options/laser_scan_lens_apt").then(setApertures); + fetchOptions("/api/options/laser_scan_lens_exp").then(setExpanders); + }, [rigType]); + + // When the rig type flips, wipe fields that no longer apply + useEffect(() => { + if (isGantry(rigType)) { + form.setValue("laser_scan_lens", ""); + form.setValue("laser_scan_lens_apt", ""); + form.setValue("laser_scan_lens_exp", ""); + } else { + form.setValue("laser_focus_lens", ""); } + }, [rigType, form]); + + async function onSubmit(values: RigForm) { + const res = await fetch("/api/my/rigs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }); + + if (!res.ok) { + const msg = (await res.json().catch(() => ({} as any)))?.error || res.statusText; + toast({ title: "Failed to save rig", description: msg }); + return; + } + toast({ title: "Rig saved", description: "Your rig has been created." }); + form.reset({ ...form.getValues(), name: "" }); } + // ───────────────────────────────────────────────────────── + // UI + // ───────────────────────────────────────────────────────── return ( -
+
-

My Rigs

-

Create and manage your laser rigs.

-
- {/* top-right, unobtrusive */} +
-
-
- ( -
- - + + {/* Name */} +
+ + +

{form.formState.errors.name?.message}

+
+ + {/* Rig Type */} +
+ + +
+ + {/* Source */} +
+ + +

{form.formState.errors.laser_source?.message}

+
+ + {/* Software */} +
+ + +

{form.formState.errors.laser_software?.message}

+
+ + {/* Lens fields (conditional) */} + {!isGantry(rigType) ? ( + <> + {/* SCAN lens (galvo) */} +
+ + +

{form.formState.errors.laser_scan_lens?.message}

+
+ + {/* Optional galvo bits */} +
+
+ + +
+ +
+ + +
+
+ + ) : ( + // FOCUS lens (gantry) +
+ + +

{form.formState.errors.laser_focus_lens?.message}

)} - /> - ( -