built user portal behind auth
This commit is contained in:
parent
5c6962f4a5
commit
37d474d7c8
48 changed files with 822 additions and 496 deletions
BIN
Archive_Updated2.zip
Normal file
BIN
Archive_Updated2.zip
Normal file
Binary file not shown.
BIN
app/_app_settings.zip
Normal file
BIN
app/_app_settings.zip
Normal file
Binary file not shown.
|
|
@ -1,11 +1,28 @@
|
|||
// app/api/auth/logout/route.ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { clearAuthCookies } from "@/lib/auth-cookies";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const secure = process.env.NODE_ENV === "production";
|
||||
|
||||
export async function POST(_req: NextRequest) {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
clearAuthCookies(res);
|
||||
|
||||
res.cookies.set({
|
||||
name: "ma_at",
|
||||
value: "",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure,
|
||||
path: "/",
|
||||
expires: new Date(0), // expire immediately
|
||||
maxAge: 0,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
// Optional: support GET if you ever link to /api/auth/logout directly
|
||||
export async function GET(_req: NextRequest) {
|
||||
return POST(_req);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
// app/api/my/rigs/[id]/route.ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { directusFetch } from "@/lib/directus";
|
||||
|
|
@ -5,17 +6,16 @@ import { directusFetch } from "@/lib/directus";
|
|||
const BASE_COLLECTION = "user_rigs";
|
||||
|
||||
async function bearerFromCookies() {
|
||||
// Some Next 15 type defs model cookies() as async—await to satisfy TS in all envs.
|
||||
const store = await cookies();
|
||||
const at = store.get("ma_at")?.value;
|
||||
if (!at) throw new Error("Not authenticated");
|
||||
return `Bearer ${at}`;
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: any) {
|
||||
export async function PATCH(req: NextRequest, { params }: { params: { id?: string } }) {
|
||||
try {
|
||||
const auth = await bearerFromCookies();
|
||||
const body = await req.json();
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const id = params?.id;
|
||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ export async function PATCH(req: NextRequest, { params }: any) {
|
|||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: auth, // overrides helper's default token
|
||||
Authorization: auth, // force user-token for this call
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ export async function PATCH(req: NextRequest, { params }: any) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_req: NextRequest, { params }: any) {
|
||||
export async function DELETE(_req: NextRequest, { params }: { params: { id?: string } }) {
|
||||
try {
|
||||
const auth = await bearerFromCookies();
|
||||
const id = params?.id;
|
||||
|
|
@ -48,7 +48,7 @@ export async function DELETE(_req: NextRequest, { params }: any) {
|
|||
|
||||
await directusFetch(`/items/${BASE_COLLECTION}/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: auth },
|
||||
headers: { Authorization: auth }, // force user-token
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const BASE = process.env.DIRECTUS_URL!;
|
||||
const BASE = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
|
||||
|
||||
async function bearerFromCookies() {
|
||||
const store = await cookies();
|
||||
|
|
@ -25,6 +25,8 @@ async function getMyUserId(bearer: string) {
|
|||
export async function GET(_req: NextRequest) {
|
||||
try {
|
||||
const bearer = await bearerFromCookies();
|
||||
const myId = await getMyUserId(bearer);
|
||||
|
||||
const fields = [
|
||||
"id",
|
||||
"name",
|
||||
|
|
@ -42,10 +44,17 @@ export async function GET(_req: NextRequest) {
|
|||
"date_updated",
|
||||
].join(",");
|
||||
|
||||
const res = await fetch(
|
||||
`${BASE}/items/user_rigs?fields=${encodeURIComponent(fields)}&sort=-date_created`,
|
||||
{ headers: { Authorization: bearer, Accept: "application/json" }, cache: "no-store" }
|
||||
);
|
||||
const url = new URL(`${BASE}/items/user_rigs`);
|
||||
url.searchParams.set("fields", fields);
|
||||
url.searchParams.set("sort", "-date_created");
|
||||
// If you use a custom owner field, switch this to filter[owner][_eq]
|
||||
url.searchParams.set("filter[user_created][_eq]", myId);
|
||||
|
||||
const res = await fetch(String(url), {
|
||||
headers: { Authorization: bearer, Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const txt = await res.text();
|
||||
if (!res.ok) return NextResponse.json({ error: txt || res.statusText }, { status: res.status });
|
||||
const j = txt ? JSON.parse(txt) : { data: [] };
|
||||
|
|
@ -65,9 +74,11 @@ export async function POST(req: NextRequest) {
|
|||
try {
|
||||
const bearer = await bearerFromCookies();
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const owner = await getMyUserId(bearer);
|
||||
|
||||
const payload = { ...body, owner };
|
||||
// If your collection requires a custom 'owner' field, uncomment:
|
||||
// const owner = await getMyUserId(bearer);
|
||||
// const payload = { ...body, owner };
|
||||
const payload = body;
|
||||
|
||||
const res = await fetch(`${BASE}/items/user_rigs`, {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -44,13 +44,12 @@ export async function GET(req: NextRequest, ctx: any) {
|
|||
|
||||
const mapped = items.map((it) => ({
|
||||
id: String(it.id ?? it.submission_id ?? ""),
|
||||
label: pickLabel(it, cfg.labelFields),
|
||||
name: pickLabel(it, cfg.labelFields),
|
||||
_search: `${Object.values(it).join(" ")}`.toLowerCase(),
|
||||
})).filter((m) => !!m.id);
|
||||
|
||||
const filtered = q ? mapped.filter((m) => m._search.includes(q)) : mapped;
|
||||
filtered.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
filtered.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
|
||||
return NextResponse.json({ data: filtered.map(({ _search, ...r }) => r) });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
|
|
|
|||
26
app/api/options/laser_focus_lens/route.ts
Normal file
26
app/api/options/laser_focus_lens/route.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
export const dynamic = "force-dynamic";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const BASE = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
|
||||
const PATH = `/items/laser_focus_lens?fields=id,name&sort=name`;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const userAt = req.cookies.get("ma_at")?.value;
|
||||
if (!userAt) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
|
||||
const res = await fetch(`${BASE}${PATH}`, {
|
||||
headers: { Accept: "application/json", Authorization: `Bearer ${userAt}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const txt = await res.text();
|
||||
if (!res.ok) return NextResponse.json({ error: txt || res.statusText }, { status: res.status });
|
||||
|
||||
const j = txt ? JSON.parse(txt) : { data: [] };
|
||||
const data = (j.data ?? []).map(({ id, name }: any) => ({ id, name }));
|
||||
return NextResponse.json({ data });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ error: e?.message || "Failed to load focus lenses" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
39
app/api/options/laser_software/route.ts
Normal file
39
app/api/options/laser_software/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// app/api/options/laser_software/route.ts
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const BASE = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
|
||||
const PATH = `/items/laser_software?fields=id,name&sort=name`;
|
||||
|
||||
async function dFetch(bearer: string) {
|
||||
const res = await fetch(`${BASE}${PATH}`, {
|
||||
headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
const text = await res.text().catch(() => "");
|
||||
let json: any = null;
|
||||
try { json = text ? JSON.parse(text) : null; } catch {}
|
||||
return { res, json, text };
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const userAt = req.cookies.get("ma_at")?.value;
|
||||
if (!userAt) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
|
||||
const r = await dFetch(userAt);
|
||||
if (!r.res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Directus ${r.res.status}: ${r.text || r.res.statusText}` },
|
||||
{ status: r.res.status }
|
||||
);
|
||||
}
|
||||
|
||||
const rows: Array<{ id: number | string; name: string }> = r.json?.data ?? [];
|
||||
const data = rows.map(({ id, name }) => ({ id, name }));
|
||||
return NextResponse.json({ data });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ error: e?.message || "Failed to load software" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,65 +1,49 @@
|
|||
// app/app/api/options/laser_source/route.ts
|
||||
import { NextResponse } from "next/server";
|
||||
import { directusFetch } from "@/lib/directus";
|
||||
// app/api/options/laser_source/route.ts
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Parse "nm" that may be stored as a string (e.g., "1064", "1064nm", "1,064")
|
||||
function parseNm(v: any): number | null {
|
||||
const s = String(v ?? "").replace(/[^0-9.]/g, "");
|
||||
if (!s) return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const BASE = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
|
||||
|
||||
function buildPath(target?: string | null) {
|
||||
// If your schema supports target filtering, add it here. Otherwise we return all.
|
||||
const url = new URL(`${BASE}/items/laser_source`);
|
||||
url.searchParams.set("fields", "id,name");
|
||||
url.searchParams.set("sort", "name");
|
||||
// Example (uncomment/adjust if you actually have a `target` field or relation):
|
||||
// if (target) url.searchParams.set("filter[target][_eq]", target);
|
||||
return String(url);
|
||||
}
|
||||
|
||||
// target → wavelength range (nm)
|
||||
function nmRangeForTarget(t?: string): [number, number] | null {
|
||||
switch (t) {
|
||||
case "settings_fiber": return [1000, 1100];
|
||||
case "settings_uv": return [300, 400];
|
||||
case "settings_co2gan":
|
||||
case "settings_co2gal": return [10000, 11000];
|
||||
default: return null;
|
||||
}
|
||||
async function dFetch(bearer: string, target?: string | null) {
|
||||
const res = await fetch(buildPath(target), {
|
||||
headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
const text = await res.text().catch(() => "");
|
||||
let json: any = null;
|
||||
try { json = text ? JSON.parse(text) : null; } catch {}
|
||||
return { res, json, text };
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const target = searchParams.get("target") || undefined;
|
||||
const q = (searchParams.get("q") || "").trim().toLowerCase();
|
||||
const limit = Number(searchParams.get("limit") || "500");
|
||||
const userAt = req.cookies.get("ma_at")?.value;
|
||||
if (!userAt) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
|
||||
const range = nmRangeForTarget(target);
|
||||
if (!range) {
|
||||
return NextResponse.json({ error: "missing/invalid target" }, { status: 400 });
|
||||
const target = req.nextUrl.searchParams.get("target");
|
||||
const r = await dFetch(userAt, target);
|
||||
if (!r.res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Directus ${r.res.status}: ${r.text || r.res.statusText}` },
|
||||
{ status: r.res.status }
|
||||
);
|
||||
}
|
||||
const [lo, hi] = range;
|
||||
|
||||
// Only request fields we can read. laser_source uses submission_id as PK.
|
||||
const url = `/items/laser_source?fields=submission_id,make,model,nm&limit=${limit}`;
|
||||
const { data } = await directusFetch<{ data: any[] }>(url);
|
||||
const list = Array.isArray(data) ? data : [];
|
||||
|
||||
// Filter by nm and optional text query
|
||||
const filtered = list.filter((x) => {
|
||||
const nm = parseNm(x.nm);
|
||||
if (nm === null || nm < lo || nm > hi) return false;
|
||||
if (!q) return true;
|
||||
const label = [x.make, x.model].filter(Boolean).join(" ").toLowerCase();
|
||||
return label.includes(q);
|
||||
});
|
||||
|
||||
// Build labels and sort by make, then model
|
||||
const out = filtered
|
||||
.map((x) => ({
|
||||
id: String(x.submission_id), // critical: use submission_id, not id
|
||||
label: [x.make, x.model].filter(Boolean).join(" ") || String(x.submission_id),
|
||||
sortKey: [x.make ?? "", x.model ?? ""].join(" ").toLowerCase(),
|
||||
}))
|
||||
.sort((a, b) => a.sortKey.localeCompare(b.sortKey))
|
||||
.map(({ id, label }) => ({ id, label }));
|
||||
|
||||
return NextResponse.json({ data: out });
|
||||
const rows: Array<{ id: number | string; name: string }> = r.json?.data ?? [];
|
||||
const data = rows.map(({ id, name }) => ({ id, name }));
|
||||
return NextResponse.json({ data });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ error: e?.message || "Failed to load laser_source" }, { status: 500 });
|
||||
return NextResponse.json({ error: e?.message || "Failed to load laser sources" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,101 +1,49 @@
|
|||
// app/api/options/lens/route.ts
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { directusFetch } from "@/lib/directus";
|
||||
|
||||
/**
|
||||
* Parse "110x110", "110×110", "110 X 110", etc. -> [110, 110]
|
||||
* Returns null if we can't find two numbers.
|
||||
*/
|
||||
function parseFieldSize(s: unknown): [number, number] | null {
|
||||
if (!s) return null;
|
||||
const nums = String(s).match(/\d+(\.\d+)?/g)?.map((n) => Number(n)) ?? [];
|
||||
if (nums.length >= 2 && Number.isFinite(nums[0]) && Number.isFinite(nums[1])) {
|
||||
return [nums[0], nums[1]];
|
||||
}
|
||||
return null;
|
||||
const BASE = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
|
||||
|
||||
function buildPath(target?: string | null) {
|
||||
// Adjust the collection name if yours differs (e.g., laser_scan_lens)
|
||||
const url = new URL(`${BASE}/items/laser_scan_lens`);
|
||||
url.searchParams.set("fields", "id,name");
|
||||
url.searchParams.set("sort", "name");
|
||||
// Example if you model per-target lenses:
|
||||
// if (target) url.searchParams.set("filter[target][_eq]", target);
|
||||
return String(url);
|
||||
}
|
||||
|
||||
/** Pull a clean integer from a value like "F160", "160", "160mm" */
|
||||
function parseFocalLength(v: unknown): number | null {
|
||||
if (v == null) return null;
|
||||
const m = String(v).match(/\d+(\.\d+)?/);
|
||||
if (!m) return null;
|
||||
const n = Number(m[0]);
|
||||
return Number.isFinite(n) ? Math.round(n) : null;
|
||||
}
|
||||
|
||||
/** Natural-ish compare for scan lens sizes: sort by width then height */
|
||||
function sizeCompare(a: string, b: string): number {
|
||||
const as = parseFieldSize(a);
|
||||
const bs = parseFieldSize(b);
|
||||
if (!as && !bs) return String(a).localeCompare(String(b));
|
||||
if (!as) return 1;
|
||||
if (!bs) return -1;
|
||||
// width, then height
|
||||
if (as[0] !== bs[0]) return as[0] - bs[0];
|
||||
if (as[1] !== bs[1]) return as[1] - bs[1];
|
||||
return 0;
|
||||
async function dFetch(bearer: string, target?: string | null) {
|
||||
const res = await fetch(buildPath(target), {
|
||||
headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
const text = await res.text().catch(() => "");
|
||||
let json: any = null;
|
||||
try { json = text ? JSON.parse(text) : null; } catch {}
|
||||
return { res, json, text };
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const target = searchParams.get("target") || "";
|
||||
const q = (searchParams.get("q") || "").trim().toLowerCase();
|
||||
const limit = Number(searchParams.get("limit") || "500");
|
||||
const userAt = req.cookies.get("ma_at")?.value;
|
||||
if (!userAt) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
|
||||
const isGantry = target === "settings_co2gan";
|
||||
|
||||
if (isGantry) {
|
||||
// CO2 Gantry -> FOCUS lenses
|
||||
const url = `/items/laser_focus_lens?fields=id,name&limit=${limit}`;
|
||||
const { data } = await directusFetch<{ data: Array<{ id: string | number; name?: string }> }>(url);
|
||||
let rows = (data ?? []).map((r) => ({
|
||||
id: String(r.id),
|
||||
label: r.name?.trim() || String(r.id),
|
||||
_key: r.name?.toLowerCase() ?? "",
|
||||
}));
|
||||
|
||||
if (q) rows = rows.filter((r) => r.label.toLowerCase().includes(q));
|
||||
rows.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
return NextResponse.json({ data: rows.map(({ id, label }) => ({ id, label })) });
|
||||
const target = req.nextUrl.searchParams.get("target");
|
||||
const r = await dFetch(userAt, target);
|
||||
if (!r.res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Directus ${r.res.status}: ${r.text || r.res.statusText}` },
|
||||
{ status: r.res.status }
|
||||
);
|
||||
}
|
||||
|
||||
// Galvo/UV/Fiber -> SCAN lenses with field_size + focal_length
|
||||
const url = `/items/laser_scan_lens?fields=id,field_size,focal_length&limit=${limit}`;
|
||||
const { data } = await directusFetch<{
|
||||
data: Array<{ id: string | number; field_size?: string; focal_length?: string | number }>;
|
||||
}>(url);
|
||||
|
||||
let rows = (data ?? []).map((r) => {
|
||||
const sizeTxt = (r.field_size ?? "").toString().trim(); // e.g. "110x110"
|
||||
const fmm = parseFocalLength(r.focal_length); // e.g. 160
|
||||
// Build label: "110x110mm (F160)" or "110x110mm" if focal length missing
|
||||
const label =
|
||||
sizeTxt
|
||||
? `${sizeTxt}mm${fmm != null ? ` (F${fmm})` : ""}`
|
||||
: `${r.id}`;
|
||||
return {
|
||||
id: String(r.id),
|
||||
label,
|
||||
_size: sizeTxt,
|
||||
};
|
||||
});
|
||||
|
||||
if (q) {
|
||||
const qq = q.toLowerCase();
|
||||
rows = rows.filter((r) => r.label.toLowerCase().includes(qq));
|
||||
}
|
||||
|
||||
rows.sort((a, b) => {
|
||||
const c = sizeCompare(a._size, b._size);
|
||||
return c !== 0 ? c : a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: rows.map(({ id, label }) => ({ id, label })) });
|
||||
const rows: Array<{ id: number | string; name: string }> = r.json?.data ?? [];
|
||||
const data = rows.map(({ id, name }) => ({ id, name }));
|
||||
return NextResponse.json({ data });
|
||||
} catch (e: any) {
|
||||
console.error("[options/lens] error:", e?.message || e);
|
||||
return NextResponse.json({ error: e?.message || "Internal error" }, { status: 500 });
|
||||
return NextResponse.json({ error: e?.message || "Failed to load lenses" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
// app/api/options/rig_type/route.ts
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const BASE = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
|
||||
const SUBMIT = process.env.DIRECTUS_TOKEN_SUBMIT || ""; // fallback for anon contexts
|
||||
const ADMIN = process.env.DIRECTUS_TOKEN_ADMIN_REGISTER || ""; // last-resort fallback
|
||||
|
||||
const PATH = `/items/user_rig_type?fields=id,name&sort=sort`;
|
||||
|
||||
async function dFetch(auth?: string) {
|
||||
const headers: HeadersInit = { Accept: "application/json" };
|
||||
if (auth) headers.Authorization = `Bearer ${auth}`;
|
||||
const res = await fetch(`${BASE}${PATH}`, { headers, cache: "no-store" });
|
||||
const text = await res.text().catch(() => "");
|
||||
let json: any = null;
|
||||
try { json = text ? JSON.parse(text) : null; } catch {}
|
||||
return { res, json, text };
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// 1) Prefer the *user's* token set by login
|
||||
const userAt = req.cookies.get("ma_at")?.value;
|
||||
|
||||
let r = await dFetch(userAt);
|
||||
|
||||
// 2) If that’s forbidden/unauthorized (or they’re logged out), fall back to SUBMIT
|
||||
if ((r.res.status === 401 || r.res.status === 403) && SUBMIT) {
|
||||
r = await dFetch(SUBMIT);
|
||||
}
|
||||
|
||||
// 3) As a final fallback, try ADMIN (useful during migrations/hardening)
|
||||
if ((r.res.status === 401 || r.res.status === 403) && ADMIN) {
|
||||
r = await dFetch(ADMIN);
|
||||
}
|
||||
|
||||
if (!r.res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Directus ${r.res.status}: ${r.text || r.res.statusText}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const rows: Array<{ id: number | string; name: string }> =
|
||||
r.json?.data ?? r.json ?? [];
|
||||
|
||||
const data = rows.map(({ id, name }) => ({ id, label: name }));
|
||||
return NextResponse.json({ data }, { status: 200 });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json(
|
||||
{ error: e?.message || "Failed to load rig types" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
39
app/api/options/user_rig_type/route.ts
Normal file
39
app/api/options/user_rig_type/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// app/api/options/user_rig_type/route.ts
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const BASE = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
|
||||
const PATH = `/items/user_rig_type?fields=id,name&sort=sort`;
|
||||
|
||||
async function dFetch(bearer: string) {
|
||||
const res = await fetch(`${BASE}${PATH}`, {
|
||||
headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
const text = await res.text().catch(() => "");
|
||||
let json: any = null;
|
||||
try { json = text ? JSON.parse(text) : null; } catch {}
|
||||
return { res, json, text };
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const userAt = req.cookies.get("ma_at")?.value;
|
||||
if (!userAt) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
|
||||
const r = await dFetch(userAt);
|
||||
if (!r.res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Directus ${r.res.status}: ${r.text || r.res.statusText}` },
|
||||
{ status: r.res.status === 401 || r.res.status === 403 ? r.res.status : 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const rows: Array<{ id: number | string; name: string }> = r.json?.data ?? [];
|
||||
const data = rows.map(({ id, name }) => ({ id, name }));
|
||||
return NextResponse.json({ data }, { status: 200 });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ error: e?.message || "Failed to load rig types" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,116 +1,23 @@
|
|||
"use client";
|
||||
// app/auth/sign-in/page.tsx
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import SignIn from "./sign-in";
|
||||
|
||||
import { Suspense, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function SignInInner() {
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
|
||||
const [idVal, setIdVal] = useState("");
|
||||
const [pwVal, setPwVal] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const next = sp.get("next") || "/my/rigs";
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
identity: idVal,
|
||||
email: idVal,
|
||||
username: idVal,
|
||||
password: pwVal,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.error || "Login failed");
|
||||
|
||||
router.replace(next);
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Login failed");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
export default async function SignInPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: Record<string, string | string[] | undefined>;
|
||||
}) {
|
||||
const at = (await cookies()).get("ma_at")?.value;
|
||||
if (at) {
|
||||
redirect("/portal");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[60vh] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-2xl font-bold">Sign in</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Welcome back. Enter your credentials to continue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{err && (
|
||||
<div className="text-sm rounded border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Email or Username</label>
|
||||
<Input
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
placeholder="you@example.com or your-username"
|
||||
value={idVal}
|
||||
onChange={(e) => setIdVal(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Password</label>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={pwVal}
|
||||
onChange={(e) => setPwVal(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={submitting} className="w-full">
|
||||
{submitting ? "Signing in…" : "Sign in"}
|
||||
</Button>
|
||||
|
||||
<input type="hidden" name="next" value={next} />
|
||||
</form>
|
||||
|
||||
<p className="text-sm text-center text-muted-foreground">
|
||||
Don’t have an account?{" "}
|
||||
<Link href="/auth/sign-up" className="underline underline-offset-4 hover:opacity-80">
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const nextParam = toStr(searchParams?.next) || "/portal";
|
||||
return <SignIn nextPath={nextParam} />;
|
||||
}
|
||||
|
||||
export default function SignInPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-[60vh] flex items-center justify-center px-4">
|
||||
<div className="text-sm text-muted-foreground">Loading…</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SignInInner />
|
||||
</Suspense>
|
||||
);
|
||||
function toStr(v: string | string[] | undefined): string | undefined {
|
||||
if (!v) return undefined;
|
||||
return Array.isArray(v) ? v[0] : v;
|
||||
}
|
||||
|
|
|
|||
101
app/auth/sign-in/sign-in.tsx
Normal file
101
app/auth/sign-in/sign-in.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
// app/auth/sign-in/sign-in.tsx
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
type Props = { nextPath?: string };
|
||||
|
||||
export default function SignIn({ nextPath = "/portal" }: Props) {
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const next = sp.get("next") || nextPath;
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
credentials: "include", // ensure cookie (ma_at) is set
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const txt = await res.text();
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText || "Sign-in failed";
|
||||
try {
|
||||
const j = txt ? JSON.parse(txt) : null;
|
||||
msg = j?.error || j?.message || msg;
|
||||
} catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
// success → land on next (or /portal)
|
||||
router.replace(next || "/portal");
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Sign-in failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-md px-6 py-10">
|
||||
<h1 className="mb-6 text-2xl font-semibold tracking-tight">Sign In</h1>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="inline-flex w-full items-center justify-center rounded-md bg-black px-4 py-2 text-white disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Signing in…" : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm opacity-70">
|
||||
Don’t have an account?{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href={`/auth/sign-up?next=${encodeURIComponent(next || "/portal")}`}
|
||||
>
|
||||
Sign up
|
||||
</a>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,131 +1,23 @@
|
|||
"use client";
|
||||
// app/auth/sign-up/page.tsx
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import SignUp from "./sign-up";
|
||||
|
||||
import { Suspense, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function SignUpInner() {
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState(""); // optional
|
||||
const [password, setPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const next = sp.get("next") || "/my/rigs";
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
email: email || undefined,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.error || "Registration failed");
|
||||
|
||||
router.replace(next);
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Registration failed");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
export default async function SignUpPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: Record<string, string | string[] | undefined>;
|
||||
}) {
|
||||
const at = (await cookies()).get("ma_at")?.value;
|
||||
if (at) {
|
||||
redirect("/portal");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[60vh] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-2xl font-bold">Create account</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pick a username and password. Email is optional (recommended for password reset).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{err && (
|
||||
<div className="text-sm rounded border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Username</label>
|
||||
<Input
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Email <span className="text-muted-foreground font-normal">(optional)</span>
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Without an email, we can’t reset your password if you lose it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Password</label>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={submitting} className="w-full">
|
||||
{submitting ? "Creating…" : "Create account"}
|
||||
</Button>
|
||||
|
||||
<input type="hidden" name="next" value={next} />
|
||||
</form>
|
||||
|
||||
<p className="text-sm text-center text-muted-foreground">
|
||||
Already have an account?{" "}
|
||||
<Link href="/auth/sign-in" className="underline underline-offset-4 hover:opacity-80">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const nextParam = toStr(searchParams?.next) || "/portal";
|
||||
return <SignUp nextPath={nextParam} />;
|
||||
}
|
||||
|
||||
export default function SignUpPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-[60vh] flex items-center justify-center px-4">
|
||||
<div className="text-sm text-muted-foreground">Loading…</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SignUpInner />
|
||||
</Suspense>
|
||||
);
|
||||
function toStr(v: string | string[] | undefined): string | undefined {
|
||||
if (!v) return undefined;
|
||||
return Array.isArray(v) ? v[0] : v;
|
||||
}
|
||||
|
|
|
|||
138
app/auth/sign-up/sign-up.tsx
Normal file
138
app/auth/sign-up/sign-up.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// app/auth/sign-up/sign-up.tsx
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Props = {
|
||||
nextPath?: string; // where to go after successful sign-up
|
||||
};
|
||||
|
||||
export default function SignUp({ nextPath = "/portal" }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState(""); // optional per your backend flow
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = 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/register", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
email: email || undefined,
|
||||
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 ||
|
||||
(typeof j === "string" ? j : "") ||
|
||||
`Sign-up failed (${res.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
// Expect server to create user + set cookies (ma_at, etc.)
|
||||
router.replace(nextPath || "/portal");
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Unable to sign up.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [username, email, password, nextPath, router]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md rounded-lg border p-6">
|
||||
<h1 className="mb-1 text-2xl font-semibold">Create Account</h1>
|
||||
<p className="mb-6 text-sm opacity-70">Join MakerDash to manage rigs, settings, and projects.</p>
|
||||
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
placeholder="your-handle"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Email <span className="opacity-60">(optional)</span></label>
|
||||
<input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
/>
|
||||
</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="new-password"
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
placeholder="Choose a strong password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-black px-3 py-2 text-white disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Creating account…" : "Sign Up"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
<span className="opacity-70">Already have an account?</span>{" "}
|
||||
<a className="underline" href={`/auth/sign-in?next=${encodeURIComponent(nextPath)}`}>
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
// app/my/rigs/RigBuilderClient.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
|
@ -5,12 +6,18 @@ import { useForm } from "react-hook-form";
|
|||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
let _redirectingDueToAuth = false;
|
||||
function handleAuthError(err: any): boolean {
|
||||
const status = (err as any)?.status;
|
||||
const code = (err as any)?.code;
|
||||
if (status === 401 || code === "TOKEN_EXPIRED") {
|
||||
const next = encodeURIComponent(window.location.pathname + window.location.search);
|
||||
window.location.assign(`/auth/sign-in?next=${next}`);
|
||||
if (_redirectingDueToAuth) return true;
|
||||
_redirectingDueToAuth = true;
|
||||
|
||||
const here = window.location.pathname + window.location.search;
|
||||
const onSignIn = window.location.pathname.startsWith("/auth");
|
||||
const next = encodeURIComponent(here);
|
||||
window.location.replace(onSignIn ? `/auth/sign-in` : `/auth/sign-in?next=${next}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
|
@ -40,7 +47,7 @@ import { Badge } from "@/components/ui/badge";
|
|||
// Types
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
type Option = { id: string | number; label: string };
|
||||
type Option = { id: string | number; name: string };
|
||||
type RigType = { id: number | string; name: "fiber" | "uv" | "co2_galvo" | "co2_gantry" | string };
|
||||
|
||||
type RigRow = {
|
||||
|
|
@ -54,7 +61,6 @@ type RigRow = {
|
|||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Builder rig_type -> settings form target expected by options API
|
||||
const SETTINGS_TARGET_MAP: Record<string, string> = {
|
||||
fiber: "settings_fiber",
|
||||
co2_gantry: "settings_co2gan",
|
||||
|
|
@ -73,7 +79,6 @@ async function apiJson<T>(url: string, init?: RequestInit): Promise<T> {
|
|||
if (res.ok) {
|
||||
try { return JSON.parse(txt) as T; } catch { return undefined as T; }
|
||||
}
|
||||
// try to unwrap nested error format
|
||||
let body: any = undefined;
|
||||
try { body = JSON.parse(txt); } catch {}
|
||||
if (body && typeof body.error === "string") {
|
||||
|
|
@ -121,10 +126,8 @@ export default function RigBuilderClient() {
|
|||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const swJson = await apiJson<any>(`/api/options/laser_software`);
|
||||
const sw =
|
||||
Array.isArray(swJson?.data) ? swJson.data :
|
||||
Array.isArray(swJson) ? swJson : [];
|
||||
const swJson = await apiJson<{ data: Option[] }>(`/api/options/laser_software`);
|
||||
const sw = Array.isArray(swJson?.data) ? swJson.data : [];
|
||||
setSoftwareOpts(sw);
|
||||
} catch (e: any) {
|
||||
if (!handleAuthError(e)) {
|
||||
|
|
@ -168,13 +171,13 @@ export default function RigBuilderClient() {
|
|||
(async () => {
|
||||
try {
|
||||
const [typesRes, rigsRes] = await Promise.all([
|
||||
apiJson<{ data: { id: number | string; label?: string; name?: string }[] }>(`/api/options/user_rig_type`),
|
||||
apiJson<{ data: { id: number | string; name: string }[] }>(`/api/options/user_rig_type`),
|
||||
apiJson<{ data: RigRow[] }>(`/api/my/rigs`),
|
||||
]);
|
||||
|
||||
const mappedTypes: RigType[] = (typesRes?.data ?? []).map((t) => ({
|
||||
id: t.id,
|
||||
name: (t.label ?? t.name ?? String(t.id)) as any,
|
||||
name: t.name as any,
|
||||
}));
|
||||
setRigTypes(mappedTypes);
|
||||
setRigs(rigsRes.data ?? []);
|
||||
|
|
@ -255,7 +258,10 @@ export default function RigBuilderClient() {
|
|||
try {
|
||||
const payload = {
|
||||
name: values.name,
|
||||
rig_type: rigTypes.find((t) => String(t.name) === String(values.rig_type))?.id ?? values.rig_type,
|
||||
// Your select uses the slug (name) as value; map it back to id for save:
|
||||
rig_type:
|
||||
rigTypes.find((t) => String(t.name) === String(values.rig_type))?.id ??
|
||||
values.rig_type,
|
||||
laser_source: values.laser_source || null,
|
||||
laser_software: values.laser_software || null,
|
||||
laser_focus_lens: isGantry ? values.laser_focus_lens || null : null,
|
||||
|
|
@ -325,16 +331,12 @@ export default function RigBuilderClient() {
|
|||
const rigTypeItems = useMemo(
|
||||
() =>
|
||||
rigTypes.map((t) => ({
|
||||
value: String(t.name),
|
||||
value: String(t.name), // using slug as value per your current pattern
|
||||
label: String(t.name).replaceAll("_", " "),
|
||||
})),
|
||||
[rigTypes]
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// UI
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Card>
|
||||
|
|
@ -380,7 +382,7 @@ export default function RigBuilderClient() {
|
|||
<SelectItem value="none">—</SelectItem>
|
||||
{sourceOpts.map((o) => (
|
||||
<SelectItem key={o.id} value={String(o.id)}>
|
||||
{o.label}
|
||||
{o.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -401,14 +403,14 @@ export default function RigBuilderClient() {
|
|||
<SelectItem value="none">—</SelectItem>
|
||||
{softwareOpts.map((o) => (
|
||||
<SelectItem key={o.id} value={String(o.id)}>
|
||||
{o.label}
|
||||
{o.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Notes spans 2 cols */}
|
||||
{/* Notes */}
|
||||
<div className="col-span-1 md:col-span-2 space-y-2">
|
||||
<label className="text-sm font-medium">Notes</label>
|
||||
<Textarea placeholder="Optional notes…" rows={4} {...register("notes")} />
|
||||
|
|
@ -430,7 +432,7 @@ export default function RigBuilderClient() {
|
|||
<SelectItem value="none">—</SelectItem>
|
||||
{focusLensOpts.map((o) => (
|
||||
<SelectItem key={o.id} value={String(o.id)}>
|
||||
{o.label}
|
||||
{o.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -455,7 +457,7 @@ export default function RigBuilderClient() {
|
|||
<SelectItem value="none">—</SelectItem>
|
||||
{scanLensOpts.map((o) => (
|
||||
<SelectItem key={o.id} value={String(o.id)}>
|
||||
{o.label}
|
||||
{o.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
|
|||
9
app/portal/account/page.tsx
Normal file
9
app/portal/account/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// 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>
|
||||
);
|
||||
}
|
||||
13
app/portal/laser-settings/page.tsx
Normal file
13
app/portal/laser-settings/page.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// app/portal/laser-settings/page.tsx
|
||||
import SettingsSwitcher from "@/components/portal/SettingsSwitcher";
|
||||
|
||||
export const metadata = { title: "MakerDash • Laser Settings" };
|
||||
|
||||
export default function LaserSettingsPortalPage() {
|
||||
return (
|
||||
<div className="rounded-lg border p-6">
|
||||
<h2 className="mb-4 text-xl font-semibold">Laser Settings</h2>
|
||||
<SettingsSwitcher />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
app/portal/laser-sources/page.tsx
Normal file
9
app/portal/laser-sources/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// app/portal/laser-sources/page.tsx
|
||||
export default function LaserSourcesPage() {
|
||||
return (
|
||||
<div className="rounded-lg border p-6">
|
||||
<h2 className="text-xl font-semibold mb-2">Laser Sources</h2>
|
||||
<p className="opacity-80">WIP: list & manage sources here.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
app/portal/layout.tsx
Normal file
30
app/portal/layout.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// app/portal/layout.tsx
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import PortalTabs from "@/components/PortalTabs";
|
||||
import SignOutButton from "@/components/SignOutButton";
|
||||
|
||||
export const metadata = { title: "MakerDash" };
|
||||
|
||||
export default async function PortalLayout({ children }: { children: React.ReactNode }) {
|
||||
// Auth gate: require user access token cookie
|
||||
const store = await cookies();
|
||||
const at = store.get("ma_at")?.value;
|
||||
if (!at) {
|
||||
// preserve deep-link by defaulting to /portal
|
||||
redirect(`/auth/sign-in?next=${encodeURIComponent("/portal")}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-6 py-6">
|
||||
<header className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Welcome to MakerDash</h1>
|
||||
<SignOutButton className="text-sm opacity-75 hover:opacity-100" />
|
||||
</header>
|
||||
|
||||
<PortalTabs />
|
||||
|
||||
<section className="mt-6">{children}</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
9
app/portal/materials/page.tsx
Normal file
9
app/portal/materials/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// app/portal/materials/page.tsx
|
||||
export default function MaterialsPage() {
|
||||
return (
|
||||
<div className="rounded-lg border p-6">
|
||||
<h2 className="text-xl font-semibold mb-2">Materials</h2>
|
||||
<p className="opacity-80">WIP: materials library management.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
app/portal/page.tsx
Normal file
12
app/portal/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// app/portal/page.tsx
|
||||
export default function PortalHome() {
|
||||
return (
|
||||
<div className="rounded-lg border p-6">
|
||||
<h2 className="text-xl font-semibold mb-2">Dashboard</h2>
|
||||
<p className="opacity-80">
|
||||
Pick a tab to get started. You can add and manage Rigs, Laser Settings, Sources, Materials, Projects,
|
||||
or jump into Utilities and Account.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
app/portal/projects/page.tsx
Normal file
9
app/portal/projects/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// app/portal/projects/page.tsx
|
||||
export default function ProjectsPage() {
|
||||
return (
|
||||
<div className="rounded-lg border p-6">
|
||||
<h2 className="text-xl font-semibold mb-2">Projects</h2>
|
||||
<p className="opacity-80">WIP: authenticated project list & details.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/portal/rigs/page.tsx
Normal file
14
app/portal/rigs/page.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// app/portal/rigs/page.tsx
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
// If you already have RigBuilderClient, you can import it here instead of this placeholder:
|
||||
// const RigBuilderClient = dynamic(() => import("@/app/my/rigs/RigBuilderClient"), { ssr: false });
|
||||
|
||||
export default function RigsPage() {
|
||||
return (
|
||||
<div className="rounded-lg border p-6">
|
||||
<h2 className="text-xl font-semibold mb-2">Rigs</h2>
|
||||
<p className="opacity-80">We’ll plug the existing Rig Builder here.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
app/portal/utilities/page.tsx
Normal file
9
app/portal/utilities/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// app/portal/utilities/page.tsx
|
||||
export default function UtilitiesPage() {
|
||||
return (
|
||||
<div className="rounded-lg border p-6">
|
||||
<h2 className="text-xl font-semibold mb-2">Utilities</h2>
|
||||
<p className="opacity-80">WIP: calculators, helpers, import/export, etc.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
components/PortalTabs.tsx
Normal file
50
components/PortalTabs.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// components/PortalTabs.tsx
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils"; // or roll your own `cn` if you don’t have one
|
||||
|
||||
const tabs = [
|
||||
{ href: "/portal", label: "Home" },
|
||||
{ href: "/portal/rigs", label: "Rigs" },
|
||||
{ href: "/portal/laser-settings", label: "Laser Settings" },
|
||||
{ href: "/portal/laser-sources", label: "Laser Sources" },
|
||||
{ href: "/portal/materials", label: "Materials" },
|
||||
{ href: "/portal/projects", label: "Projects" },
|
||||
{ href: "/portal/utilities", label: "Utilities" },
|
||||
{ href: "/portal/account", label: "Account" },
|
||||
];
|
||||
|
||||
export default function PortalTabs() {
|
||||
const pathname = usePathname() || "/portal";
|
||||
|
||||
return (
|
||||
<nav className="flex flex-wrap items-center gap-1 rounded-md border bg-background p-1">
|
||||
{tabs.map((t) => {
|
||||
const active =
|
||||
pathname === t.href ||
|
||||
(t.href !== "/portal" && pathname.startsWith(t.href));
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={t.href}
|
||||
href={t.href}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm rounded-md transition",
|
||||
active
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="ml-auto px-3 py-1.5 text-xs opacity-60">
|
||||
MakerDash
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,11 +22,15 @@ export default function SignOutButton({
|
|||
if (pending) return;
|
||||
setPending(true);
|
||||
try {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
credentials: "include", // make sure cookies are cleared
|
||||
});
|
||||
|
||||
// include ?next= so they can land back here after re-auth if desired
|
||||
const next = pathname ? `?next=${encodeURIComponent(pathname)}` : "";
|
||||
const next = pathname ? `?next=${encodeURIComponent(pathname)}` : "?next=/portal";
|
||||
router.push(redirectTo + next);
|
||||
router.refresh(); // ensure cookies are revalidated client-side
|
||||
router.refresh();
|
||||
} catch {
|
||||
router.push(redirectTo);
|
||||
} finally {
|
||||
|
|
|
|||
76
components/portal/SettingsSwitcher.tsx
Normal file
76
components/portal/SettingsSwitcher.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// components/portal/SettingsSwitcher.tsx
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import dynamic from "next/dynamic";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* IMPORTANT:
|
||||
* We dynamically import the existing settings pages so you do NOT have to move their contents.
|
||||
* These imports point directly at your canonical routes:
|
||||
* /app/settings/fiber/page.tsx
|
||||
* /app/settings/uv/page.tsx
|
||||
* /app/settings/co2-galvo/page.tsx
|
||||
* /app/settings/co2-gantry/page.tsx
|
||||
*
|
||||
* If any of those filenames differ, update the paths below accordingly.
|
||||
*/
|
||||
const FiberPanel = dynamic(() => import("@/app/settings/fiber/page"), { ssr: false });
|
||||
const UVPanel = dynamic(() => import("@/app/settings/uv/page"), { ssr: false });
|
||||
const CO2GalvoPanel = dynamic(() => import("@/app/settings/co2-galvo/page"), { ssr: false });
|
||||
const CO2GantryPanel = dynamic(() => import("@/app/settings/co2-gantry/page"), { ssr: false });
|
||||
|
||||
const TABS = [
|
||||
{ key: "fiber", label: "Fiber" },
|
||||
{ key: "uv", label: "UV" },
|
||||
{ key: "co2-galvo", label: "CO₂ Galvo" },
|
||||
{ key: "co2-gantry", label: "CO₂ Gantry" },
|
||||
];
|
||||
|
||||
function Panel({ tab }: { tab: string }) {
|
||||
switch (tab) {
|
||||
case "fiber": return <FiberPanel />;
|
||||
case "uv": return <UVPanel />;
|
||||
case "co2-galvo": return <CO2GalvoPanel />;
|
||||
case "co2-gantry": return <CO2GantryPanel />;
|
||||
default: return <FiberPanel />;
|
||||
}
|
||||
}
|
||||
|
||||
export default function SettingsSwitcher() {
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
const active = sp.get("t") || "fiber";
|
||||
|
||||
function setTab(nextKey: string) {
|
||||
const q = new URLSearchParams(sp.toString());
|
||||
q.set("t", nextKey);
|
||||
router.replace(`/portal/laser-settings?${q.toString()}`, { scroll: false });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* sub-tabs */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
{TABS.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTab(key)}
|
||||
className={cn(
|
||||
"rounded-md border px-3 py-1.5 text-sm transition",
|
||||
active === key ? "bg-primary text-primary-foreground" : "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* panel */}
|
||||
<div className="rounded-md border p-4">
|
||||
<Panel tab={active} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
// utils.ts
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,67 @@
|
|||
// middleware.ts
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
|
||||
const PUBLIC_PATHS = new Set<string>([
|
||||
"/auth/sign-in",
|
||||
"/auth/sign-up",
|
||||
]);
|
||||
|
||||
// If you have additional public pages (e.g., marketing), add them here.
|
||||
// Keep API endpoints out of this middleware unless you explicitly want to block them.
|
||||
|
||||
export function middleware(req: NextRequest) {
|
||||
const { pathname, searchParams, origin } = req.nextUrl;
|
||||
const { pathname, search } = req.nextUrl;
|
||||
const isPublic = isPublicPath(pathname);
|
||||
const isAuthRoute = pathname.startsWith("/auth/");
|
||||
const token = req.cookies.get("ma_at")?.value ?? "";
|
||||
|
||||
const isAuthPage =
|
||||
pathname === "/auth/sign-in" || pathname === "/auth/sign-up";
|
||||
const isMyArea = pathname.startsWith("/my/");
|
||||
|
||||
const at = req.cookies.get("ma_at")?.value;
|
||||
|
||||
// Gate /my/*
|
||||
if (isMyArea && !at) {
|
||||
const dest = new URL("/auth/sign-in", origin);
|
||||
dest.searchParams.set("next", pathname + (req.nextUrl.search || ""));
|
||||
return NextResponse.redirect(dest);
|
||||
// 1) If already authed and on an auth route, dump to /portal
|
||||
if (token && isAuthRoute) {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = "/portal";
|
||||
url.search = "";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
// If logged in and on auth pages, send to next or /my/rigs
|
||||
if (isAuthPage && at) {
|
||||
const nxt = searchParams.get("next") || "/my/rigs";
|
||||
return NextResponse.redirect(new URL(nxt, origin));
|
||||
// 2) If not authed and path is protected → send to sign-in with next=<original>
|
||||
if (!token && !isPublic) {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = "/auth/sign-in";
|
||||
// Default to /portal after login, but preserve deep-link if present
|
||||
const next = pathname + (search || "");
|
||||
url.search = next ? `?next=${encodeURIComponent(next)}` : `?next=${encodeURIComponent("/portal")}`;
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
// 3) Otherwise, allow through
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Helpers
|
||||
function isPublicPath(pathname: string): boolean {
|
||||
// Public routes
|
||||
if (PUBLIC_PATHS.has(pathname)) return true;
|
||||
|
||||
// Static assets and framework internals
|
||||
if (
|
||||
pathname.startsWith("/_next/") ||
|
||||
pathname.startsWith("/static/") ||
|
||||
pathname.startsWith("/images/") ||
|
||||
pathname === "/favicon.ico" ||
|
||||
pathname === "/robots.txt" ||
|
||||
pathname === "/sitemap.xml"
|
||||
) return true;
|
||||
|
||||
// API routes: by default we *do not* block /api/* in middleware (let routes handle auth)
|
||||
if (pathname.startsWith("/api/")) return true;
|
||||
|
||||
// Everything else is protected
|
||||
return false;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/my/:path*", "/auth/sign-in", "/auth/sign-up"],
|
||||
// Run middleware for all paths except the most common static files (belt & suspenders)
|
||||
matcher: [
|
||||
"/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|images|static).*)",
|
||||
],
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue