directus permissions fixes and rig_type addition
This commit is contained in:
parent
4deeac8e43
commit
226fcc8013
3 changed files with 355 additions and 289 deletions
|
|
@ -3,68 +3,71 @@ import { NextRequest, NextResponse } from "next/server";
|
||||||
import { setAuthCookies } from "@/lib/auth-cookies";
|
import { setAuthCookies } from "@/lib/auth-cookies";
|
||||||
|
|
||||||
const BASE = process.env.DIRECTUS_URL!;
|
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) {
|
async function findEmailForIdentifier(identifier: string): Promise<string | null> {
|
||||||
const text = await res.text();
|
const id = (identifier || "").trim();
|
||||||
try { return { json: text ? JSON.parse(text) : null, text }; }
|
if (!id) return null;
|
||||||
catch { return { json: null as any, text }; }
|
|
||||||
|
// 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) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json();
|
||||||
const identity: string = (body.identity || body.usernameOrEmail || "").trim();
|
const identifier = (body?.identifier ?? body?.email ?? "").trim();
|
||||||
const password: string = String(body.password || "");
|
const password = body?.password ?? "";
|
||||||
|
|
||||||
if (!identity || !password) {
|
if (!identifier || !password) {
|
||||||
return NextResponse.json({ error: "Missing identity or password" }, { status: 400 });
|
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`, {
|
const loginRes = await fetch(`${BASE}/auth/login`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
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) {
|
if (!loginRes.ok) {
|
||||||
const msg =
|
const msg = loginJson?.errors?.[0]?.message || loginRes.statusText;
|
||||||
loginJson?.errors?.[0]?.message ||
|
return NextResponse.json({ error: msg }, { status: loginRes.status });
|
||||||
loginJson?.message ||
|
|
||||||
`Directus login failed: ${loginRes.status} ${loginRes.statusText}`;
|
|
||||||
return NextResponse.json({ error: msg }, { status: 401 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const access = loginJson?.data?.access_token || loginJson?.access_token;
|
const tokens = loginJson?.data ?? loginJson ?? {};
|
||||||
const refresh = loginJson?.data?.refresh_token || loginJson?.refresh_token;
|
const access = tokens.access_token;
|
||||||
if (!access || !refresh) {
|
const refresh = tokens.refresh_token;
|
||||||
return NextResponse.json(
|
if (!access) {
|
||||||
{ error: `No tokens returned from Directus: ${loginText?.slice(0, 200) || "<empty>"}` },
|
return NextResponse.json({ error: "Login failed (no token)" }, { status: 500 });
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch user profile
|
// Fetch user profile for the client
|
||||||
const meRes = await fetch(`${BASE}/users/me`, {
|
const meRes = await fetch(`${BASE}/users/me?fields=id,email,username`, {
|
||||||
headers: { Authorization: `Bearer ${access}`, Accept: "application/json" },
|
headers: { Authorization: `Bearer ${access}`, Accept: "application/json" },
|
||||||
cache: "no-store",
|
|
||||||
});
|
});
|
||||||
const { json: meJson } = await jsonSafe(meRes);
|
const meJson: any = await meRes.json().catch(() => null);
|
||||||
if (!meRes.ok) {
|
const user = (meJson?.data ?? meJson) || {};
|
||||||
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 ?? ""),
|
|
||||||
};
|
|
||||||
|
|
||||||
let res = NextResponse.json({ ok: true, user });
|
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;
|
return res;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return NextResponse.json({ error: err?.message || "Login error" }, { status: 500 });
|
return NextResponse.json({ error: err?.message || "Login error" }, { status: 500 });
|
||||||
|
|
|
||||||
|
|
@ -1,269 +1,326 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
import { useEffect, useMemo, useState } 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 { z } from "zod";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
|
||||||
import SignOutButton from "@/components/SignOutButton"; // ⟵ default import
|
|
||||||
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { Textarea } from "@/components/ui/textarea";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
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({
|
// Rig Type → options mapping
|
||||||
name: z.string().min(2, "Give your rig a short name"),
|
// These mirror existing “settings_*” targets used by the options endpoints.
|
||||||
notes: z.string().optional(),
|
// ─────────────────────────────────────────────────────────────
|
||||||
laser_source: z.string().min(1, "Laser source is required"),
|
const RIG_TYPES = [
|
||||||
laser_software: z.string().min(1, "Software is required"),
|
{ value: "settings_fiber", label: "Fiber (Galvo)" },
|
||||||
laser_scan_lens: z.string().optional().nullable(),
|
{ value: "settings_uv", label: "UV (Galvo)" },
|
||||||
laser_focus_lens: z.string().optional().nullable(),
|
{ value: "settings_co2gal", label: "CO₂ Galvo" },
|
||||||
laser_scan_lens_apt: z.string().optional().nullable(),
|
{ value: "settings_co2gan", label: "CO₂ Gantry" },
|
||||||
laser_scan_lens_exp: z.string().optional().nullable(),
|
] 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<typeof RigSchema>;
|
type RigForm = z.infer<typeof RigSchema>;
|
||||||
|
|
||||||
function useOptions(path: string) {
|
// ─────────────────────────────────────────────────────────────
|
||||||
const [opts, setOpts] = useState<Opt[]>([]);
|
// Helpers
|
||||||
const [loading, setLoading] = useState(false);
|
// ─────────────────────────────────────────────────────────────
|
||||||
const [q, setQ] = useState("");
|
async function fetchOptions(url: string): Promise<Option[]> {
|
||||||
|
const res = await fetch(url);
|
||||||
useEffect(() => {
|
if (!res.ok) return [];
|
||||||
let alive = true;
|
const json = await res.json().catch(() => null);
|
||||||
setLoading(true);
|
return json?.data || [];
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Select({
|
// Map rig_type → /api/options target
|
||||||
label, value, onChange, options, placeholder = "—", loading,
|
function targetFor(type: RigType): string {
|
||||||
}: {
|
return type;
|
||||||
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 (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm mb-1">{label}</label>
|
|
||||||
<input
|
|
||||||
className="w-full border rounded px-2 py-1 mb-1"
|
|
||||||
placeholder="Type to filter…"
|
|
||||||
value={filter}
|
|
||||||
onChange={(e) => setFilter(e.target.value)}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
className="w-full border rounded px-2 py-1"
|
|
||||||
value={value ?? ""}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">{placeholder}{loading ? " (loading…)" : ""}</option>
|
|
||||||
{filtered.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MyRigsPage() {
|
export default function MyRigsPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// options
|
const form = useForm<RigForm>({
|
||||||
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<RigForm>({
|
|
||||||
resolver: zodResolver(RigSchema),
|
resolver: zodResolver(RigSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
notes: "",
|
rig_type: "settings_fiber",
|
||||||
laser_source: "",
|
laser_source: "",
|
||||||
laser_software: "",
|
laser_software: "",
|
||||||
laser_scan_lens: "",
|
laser_scan_lens: "",
|
||||||
laser_focus_lens: "",
|
laser_focus_lens: "",
|
||||||
laser_scan_lens_apt: "",
|
laser_scan_lens_apt: "",
|
||||||
laser_scan_lens_exp: "",
|
laser_scan_lens_exp: "",
|
||||||
},
|
notes: "",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit(values: RigForm) {
|
const rigType = form.watch("rig_type");
|
||||||
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");
|
|
||||||
|
|
||||||
toast({ title: "Rig saved", description: "Your rig has been saved to your account." });
|
const [sources, setSources] = useState<Option[]>([]);
|
||||||
reset();
|
const [software, setSoftware] = useState<Option[]>([]);
|
||||||
} catch (err: any) {
|
const [scanLens, setScanLens] = useState<Option[]>([]);
|
||||||
toast({ title: "Save failed", description: err?.message || "Unknown error", variant: "destructive" });
|
const [focusLens, setFocusLens] = useState<Option[]>([]);
|
||||||
|
const [apertures, setApertures] = useState<Option[]>([]);
|
||||||
|
const [expanders, setExpanders] = useState<Option[]>([]);
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-4 space-y-6">
|
<div className="mx-auto max-w-3xl space-y-8 p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold">My Rigs</h1>
|
<h1 className="text-2xl font-semibold">My Rigs</h1>
|
||||||
<p className="text-sm text-muted-foreground">Create and manage your laser rigs.</p>
|
<SignOutButton />
|
||||||
</div>
|
|
||||||
<SignOutButton /> {/* top-right, unobtrusive */}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 border rounded p-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
{/* Name */}
|
||||||
<Controller
|
<div className="space-y-2">
|
||||||
name="name"
|
<Label htmlFor="name">Rig name</Label>
|
||||||
control={control}
|
<Input id="name" {...form.register("name")} placeholder="e.g. Shop Fiber #1" />
|
||||||
render={({ field }) => (
|
<p className="text-sm text-muted-foreground">{form.formState.errors.name?.message}</p>
|
||||||
<div>
|
</div>
|
||||||
<label className="block text-sm mb-1">Rig Name *</label>
|
|
||||||
<Input {...field} placeholder="e.g., Shop Fiber #1" />
|
{/* Rig Type */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Rig type</Label>
|
||||||
|
<Select
|
||||||
|
value={rigType}
|
||||||
|
onValueChange={(v) => form.setValue("rig_type", v as RigType)}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Choose a rig type" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{RIG_TYPES.map((t) => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Laser source</Label>
|
||||||
|
<Select
|
||||||
|
value={form.watch("laser_source") || ""}
|
||||||
|
onValueChange={(v) => form.setValue("laser_source", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select a source" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sources.map((o) => (
|
||||||
|
<SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-sm text-muted-foreground">{form.formState.errors.laser_source?.message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Software */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Software</Label>
|
||||||
|
<Select
|
||||||
|
value={form.watch("laser_software") || ""}
|
||||||
|
onValueChange={(v) => form.setValue("laser_software", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select software" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{software.map((o) => (
|
||||||
|
<SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-sm text-muted-foreground">{form.formState.errors.laser_software?.message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lens fields (conditional) */}
|
||||||
|
{!isGantry(rigType) ? (
|
||||||
|
<>
|
||||||
|
{/* SCAN lens (galvo) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Scan lens</Label>
|
||||||
|
<Select
|
||||||
|
value={form.watch("laser_scan_lens") || ""}
|
||||||
|
onValueChange={(v) => form.setValue("laser_scan_lens", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select a scan lens" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{scanLens.map((o) => (
|
||||||
|
<SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-sm text-muted-foreground">{form.formState.errors.laser_scan_lens?.message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Optional galvo bits */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Scan head aperture (optional)</Label>
|
||||||
|
<Select
|
||||||
|
value={form.watch("laser_scan_lens_apt") || ""}
|
||||||
|
onValueChange={(v) => form.setValue("laser_scan_lens_apt", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select aperture" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{apertures.map((o) => (
|
||||||
|
<SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Beam expander (optional)</Label>
|
||||||
|
<Select
|
||||||
|
value={form.watch("laser_scan_lens_exp") || ""}
|
||||||
|
onValueChange={(v) => form.setValue("laser_scan_lens_exp", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select expander" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{expanders.map((o) => (
|
||||||
|
<SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// FOCUS lens (gantry)
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Focus lens</Label>
|
||||||
|
<Select
|
||||||
|
value={form.watch("laser_focus_lens") || ""}
|
||||||
|
onValueChange={(v) => form.setValue("laser_focus_lens", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select a focus lens" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{focusLens.map((o) => (
|
||||||
|
<SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-sm text-muted-foreground">{form.formState.errors.laser_focus_lens?.message}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
|
||||||
<Controller
|
{/* Notes */}
|
||||||
name="laser_software"
|
<div className="space-y-2">
|
||||||
control={control}
|
<Label htmlFor="notes">Notes (optional)</Label>
|
||||||
render={({ field }) => (
|
<Textarea id="notes" rows={4} {...form.register("notes")} placeholder="Anything special about this rig…" />
|
||||||
<Select
|
|
||||||
label="Software *"
|
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
options={soft.opts}
|
|
||||||
loading={soft.loading}
|
|
||||||
placeholder="Select software"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
<div className="flex gap-3">
|
||||||
<Controller
|
<Button type="submit">Save Rig</Button>
|
||||||
name="laser_source"
|
<Button type="button" variant="secondary" onClick={() => form.reset()}>Clear</Button>
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Select
|
|
||||||
label="Laser Source *"
|
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
options={srcs.opts}
|
|
||||||
loading={srcs.loading}
|
|
||||||
placeholder="Select source"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
name="laser_scan_lens"
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Select
|
|
||||||
label="Scan Lens"
|
|
||||||
value={field.value ?? ""}
|
|
||||||
onChange={field.onChange}
|
|
||||||
options={scan.opts}
|
|
||||||
loading={scan.loading}
|
|
||||||
placeholder="Select scan lens (galvo)"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
|
||||||
<Controller
|
|
||||||
name="laser_focus_lens"
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Select
|
|
||||||
label="Focus Lens"
|
|
||||||
value={field.value ?? ""}
|
|
||||||
onChange={field.onChange}
|
|
||||||
options={focus.opts}
|
|
||||||
loading={focus.loading}
|
|
||||||
placeholder="Select focus lens (gantry)"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
name="laser_scan_lens_apt"
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Select
|
|
||||||
label="Scan Head Aperture"
|
|
||||||
value={field.value ?? ""}
|
|
||||||
onChange={field.onChange}
|
|
||||||
options={apt.opts}
|
|
||||||
loading={apt.loading}
|
|
||||||
placeholder="Select aperture (optional)"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
|
||||||
<Controller
|
|
||||||
name="laser_scan_lens_exp"
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Select
|
|
||||||
label="Beam Expander"
|
|
||||||
value={field.value ?? ""}
|
|
||||||
onChange={field.onChange}
|
|
||||||
options={exp.opts}
|
|
||||||
loading={exp.loading}
|
|
||||||
placeholder="Select expander (optional)"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
name="notes"
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm mb-1">Notes</label>
|
|
||||||
<Textarea rows={4} placeholder="Any rig notes…" {...field} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant="secondary">Owner-only</Badge>
|
|
||||||
<span className="text-sm text-muted-foreground">Rigs are private to your account.</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
|
||||||
{isSubmitting ? "Saving…" : "Save Rig"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,49 @@
|
||||||
// components/SignOutButton.tsx
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className?: string;
|
||||||
|
/** Where to send users after logout */
|
||||||
|
redirectTo?: string; // default /auth/sign-in
|
||||||
|
};
|
||||||
|
|
||||||
export default function SignOutButton({
|
export default function SignOutButton({
|
||||||
className,
|
className,
|
||||||
redirectTo = "/sign-in",
|
redirectTo = "/auth/sign-in",
|
||||||
children = "Sign out",
|
}: Props) {
|
||||||
}: {
|
const [pending, setPending] = useState(false);
|
||||||
className?: string;
|
const router = useRouter();
|
||||||
redirectTo?: string;
|
const pathname = usePathname();
|
||||||
children?: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const r = useRouter();
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
async function onClick() {
|
async function onClick() {
|
||||||
if (busy) return;
|
if (pending) return;
|
||||||
setBusy(true);
|
setPending(true);
|
||||||
try {
|
try {
|
||||||
await fetch("/api/auth/logout", { method: "POST" });
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
r.push(redirectTo);
|
// include ?next= so they can land back here after re-auth if desired
|
||||||
r.refresh();
|
const next = pathname ? `?next=${encodeURIComponent(pathname)}` : "";
|
||||||
|
router.push(redirectTo + next);
|
||||||
|
router.refresh(); // ensure cookies are revalidated client-side
|
||||||
} catch {
|
} catch {
|
||||||
// ignore; worst case user can hard-refresh
|
router.push(redirectTo);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setPending(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={className ?? "text-sm underline hover:opacity-80"}
|
className={className}
|
||||||
disabled={busy}
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={pending}
|
||||||
>
|
>
|
||||||
{busy ? "Signing out…" : children}
|
{pending ? "Signing out…" : "Sign out"}
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue