Compare commits
10 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba77186161 | |||
| 647c58b3e3 | |||
| c18ca5f558 | |||
| d610379337 | |||
| fba693f761 | |||
| 7502c3e4c1 | |||
| fd0fb78699 | |||
| 886059f5e2 | |||
| a77db7e781 | |||
| 6743c0b83b |
14 changed files with 297 additions and 212 deletions
39
.env.local
39
.env.local
|
|
@ -1,9 +1,42 @@
|
||||||
# Client-side (used by the dropdown fetches)
|
# ─────────────────────────────────────────────
|
||||||
|
# Public (used by client-side dropdown fetches)
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
NEXT_PUBLIC_API_BASE_URL=https://forms.lasereverything.net
|
NEXT_PUBLIC_API_BASE_URL=https://forms.lasereverything.net
|
||||||
|
|
||||||
# Server-side (used by API routes)
|
# ─────────────────────────────────────────────
|
||||||
|
# Server-side Directus
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
DIRECTUS_URL=https://forms.lasereverything.net
|
DIRECTUS_URL=https://forms.lasereverything.net
|
||||||
DIRECTUS_TOKEN_ADMIN_REGISTER=l_QqNXKpi--Dt-hHDncHyBX0eiHNYZr7
|
DIRECTUS_TOKEN_ADMIN_REGISTER=l_QqNXKpi--Dt-hHDncHyBX0eiHNYZr7
|
||||||
|
DIRECTUS_DEFAULT_ROLE=296a28bc-60ab-4251-8bef-27f6dfb67948
|
||||||
|
DIRECTUS_ROLE_MEMBER_NAME=Users
|
||||||
|
|
||||||
# Image Folders
|
# ─────────────────────────────────────────────
|
||||||
|
# Files / Folders (IDs only; no folder browsing)
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
DIRECTUS_AVATAR_FOLDER_ID=b8ddddf8-3ee3-4380-b27e-c7a5f01deef1
|
DIRECTUS_AVATAR_FOLDER_ID=b8ddddf8-3ee3-4380-b27e-c7a5f01deef1
|
||||||
|
|
||||||
|
# Settings — CO₂ Galvo
|
||||||
|
DX_FOLDER_GALVO_NOTES=7b04a706-754d-4302-a9a0-6c88cd8faddf
|
||||||
|
DX_FOLDER_GALVO_PHOTOS=e5535371-828a-498b-80fc-3891b6220fd4
|
||||||
|
DX_FOLDER_GALVO_SCREENS=8201e4c0-c39c-456a-bd55-1beb96642bcb
|
||||||
|
|
||||||
|
# Settings — CO₂ Gantry
|
||||||
|
DX_FOLDER_GANTRY_NOTES=926e2c1a-7907-4ef2-b778-859c6f40ba82
|
||||||
|
DX_FOLDER_GANTRY_PHOTOS=d19c4f8d-a42f-422d-b113-b89b736c34e6
|
||||||
|
DX_FOLDER_GANTRY_SCREENS=9b7d0b47-c1f4-4749-8876-2e4b52ccded0
|
||||||
|
|
||||||
|
# Settings — Fiber
|
||||||
|
DX_FOLDER_FIBER_NOTES=00eed759-480e-43cc-9de3-854dc59cca79
|
||||||
|
DX_FOLDER_FIBER_PHOTOS=54f6a9d2-bc57-41fc-8c7d-7c7d7cb9cadc
|
||||||
|
DX_FOLDER_FIBER_SCREENS=5c830975-7926-4e01-911c-2443b62d7f88
|
||||||
|
|
||||||
|
# Settings — UV
|
||||||
|
DX_FOLDER_UV_NOTES=8ca37379-7178-48b2-8670-6b8d8a880677
|
||||||
|
DX_FOLDER_UV_PHOTOS=c639360b-3116-4b5d-98da-f8b502089486
|
||||||
|
DX_FOLDER_UV_SCREENS=a84f54b1-0e92-4ea6-8fbe-37a3a74bd49c
|
||||||
|
|
||||||
|
# Projects
|
||||||
|
DX_FOLDER_PROJECTS_FILES=f264f066-5b38-4335-bb10-5b014bfa62cb
|
||||||
|
DX_FOLDER_PROJECTS_IMAGES=da11b876-2ede-4e19-ad3a-76fc9db449a8
|
||||||
|
DX_FOLDER_PROJECTS_INSTRUCTIONS=905a4259-0c8e-489b-b810-c27186a2f266
|
||||||
|
|
|
||||||
|
|
@ -3,71 +3,72 @@ import { NextRequest, NextResponse } from "next/server";
|
||||||
import { emailForUsername, loginDirectus } from "@/lib/directus";
|
import { emailForUsername, loginDirectus } from "@/lib/directus";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
const secure = process.env.NODE_ENV === "production";
|
const secure = process.env.NODE_ENV === "production";
|
||||||
|
|
||||||
/**
|
|
||||||
* Accepts any of:
|
|
||||||
* - { identifier: string, password: string } // email OR username in `identifier`
|
|
||||||
* - { email: string, password: string }
|
|
||||||
* - { username: string, password: string }
|
|
||||||
*
|
|
||||||
* On success: sets HttpOnly "ma_at" cookie and returns { ok: true }.
|
|
||||||
*/
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await req.json().catch(() => ({} as any));
|
const body = await req.json().catch(() => ({} as any));
|
||||||
|
const identifier =
|
||||||
|
String(body?.identifier ?? body?.email ?? body?.username ?? "").trim();
|
||||||
const password = String(body?.password ?? "").trim();
|
const password = String(body?.password ?? "").trim();
|
||||||
let identifier = String(
|
|
||||||
body?.identifier ?? body?.email ?? body?.username ?? ""
|
|
||||||
).trim();
|
|
||||||
|
|
||||||
if (!identifier || !password) {
|
if (!identifier || !password) {
|
||||||
return NextResponse.json({ error: "Missing credentials" }, { status: 400 });
|
return NextResponse.json({ error: "Missing credentials" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve to an email for Directus login:
|
// 1) Try Directus directly with the identifier (email OR username)
|
||||||
// - If identifier looks like an email, use it directly.
|
// Directus expects the field name "email" for both.
|
||||||
// - Otherwise treat it as a username and look up the email.
|
const tryIds: string[] = [identifier];
|
||||||
let email = identifier.includes("@") ? identifier : null;
|
|
||||||
if (!email) {
|
// 2) Fallback: if it doesn’t look like an email, try the canonical email (if any)
|
||||||
email = await emailForUsername(identifier);
|
if (!identifier.includes("@")) {
|
||||||
if (!email) {
|
try {
|
||||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
const em = await emailForUsername(identifier); // returns string|null
|
||||||
|
if (em && em !== identifier) tryIds.push(em);
|
||||||
|
} catch {
|
||||||
|
// ignore lookup errors, we'll just rely on the first attempt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login against Directus; helper returns { access_token, expires } (root or .data)
|
let tokens: any = null;
|
||||||
const data = await loginDirectus(email, password);
|
let lastErr: any = null;
|
||||||
|
for (const id of tryIds) {
|
||||||
const access =
|
try {
|
||||||
data?.access_token ?? data?.data?.access_token ?? null;
|
tokens = await loginDirectus(id, password); // { access_token, refresh_token, expires? }
|
||||||
const expiresSec =
|
if (tokens) break;
|
||||||
data?.expires ?? data?.data?.expires ?? null;
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
if (!access) {
|
}
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid response from auth provider" },
|
|
||||||
{ status: 502 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!tokens?.access_token) {
|
||||||
|
const msg =
|
||||||
|
lastErr?.response?.data?.errors?.[0]?.message ||
|
||||||
|
lastErr?.response?.data?.error ||
|
||||||
|
lastErr?.message ||
|
||||||
|
"Invalid credentials.";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set HttpOnly cookies for your middleware
|
||||||
|
const maxAge = 60 * 60; // 1h
|
||||||
const res = NextResponse.json({ ok: true });
|
const res = NextResponse.json({ ok: true });
|
||||||
|
res.cookies.set("ma_at", tokens.access_token, {
|
||||||
// Use provider TTL if present, else fallback to 8h
|
path: "/",
|
||||||
const maxAge =
|
|
||||||
typeof expiresSec === "number" ? Math.max(0, Math.floor(expiresSec)) : 60 * 60 * 8;
|
|
||||||
|
|
||||||
res.cookies.set({
|
|
||||||
name: "ma_at",
|
|
||||||
value: access,
|
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
secure,
|
secure,
|
||||||
path: "/",
|
|
||||||
maxAge,
|
maxAge,
|
||||||
});
|
});
|
||||||
|
if (tokens.refresh_token) {
|
||||||
|
res.cookies.set("ma_rt", tokens.refresh_token, {
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure,
|
||||||
|
maxAge: 60 * 60 * 24 * 30, // 30d
|
||||||
|
});
|
||||||
|
}
|
||||||
return res;
|
return res;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const message =
|
const message =
|
||||||
|
|
|
||||||
|
|
@ -1,140 +1,114 @@
|
||||||
// app/api/auth/register/route.ts
|
// app/api/auth/register/route.ts
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { loginDirectus } from "@/lib/directus";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
const DIRECTUS = (process.env.DIRECTUS_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
|
||||||
|
|
||||||
// Base URL (no trailing slash)
|
// Registration MUST use only the dedicated admin-register token. No fallbacks.
|
||||||
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || process.env.DIRECTUS_URL || "").replace(/\/$/, "");
|
const SERVICE_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_REGISTER || "";
|
||||||
|
|
||||||
/**
|
const DEFAULT_ROLE = process.env.DIRECTUS_DEFAULT_ROLE || undefined;
|
||||||
* Accept either:
|
const SECURE = process.env.NODE_ENV === "production";
|
||||||
* - DIRECTUS_SERVICE_TOKEN (generic name), or
|
|
||||||
* - DIRECTUS_TOKEN_ADMIN_REGISTER (your current env)
|
|
||||||
*/
|
|
||||||
const SERVICE_TOKEN =
|
|
||||||
process.env.DIRECTUS_SERVICE_TOKEN ||
|
|
||||||
process.env.DIRECTUS_TOKEN_ADMIN_REGISTER ||
|
|
||||||
"";
|
|
||||||
|
|
||||||
// Auto login right after signup (default: true)
|
function bad(message: string, status = 400) {
|
||||||
const AUTO_LOGIN = (process.env.SIGNUP_AUTO_LOGIN ?? "1") !== "0";
|
return NextResponse.json({ error: message }, { status });
|
||||||
const secure = process.env.NODE_ENV === "production";
|
|
||||||
|
|
||||||
function bad(message: string, status = 400, extra: Record<string, any> = {}) {
|
|
||||||
return NextResponse.json({ error: message, ...extra }, { status });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the role id for the role named **Users**. No fallbacks.
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
async function getUsersRoleId(): Promise<string> {
|
|
||||||
if (!API) throw new Error("DIRECTUS_URL / NEXT_PUBLIC_API_BASE_URL is not set");
|
|
||||||
if (!SERVICE_TOKEN) throw new Error("DIRECTUS_SERVICE_TOKEN / DIRECTUS_TOKEN_ADMIN_REGISTER is not set");
|
|
||||||
|
|
||||||
const r = await fetch(`${API}/roles?filter[name][_eq]=Users&fields=id,name&limit=1`, {
|
async function directusLogin(email: string, password: string) {
|
||||||
headers: { Authorization: `Bearer ${SERVICE_TOKEN}`, Accept: "application/json" },
|
const r = await fetch(`${DIRECTUS}/auth/login`, {
|
||||||
cache: "no-store",
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
const j = await r.json().catch(() => ({}));
|
const j = await r.json().catch(() => ({}));
|
||||||
if (!r.ok) {
|
if (!r.ok) throw new Error(j?.errors?.[0]?.message || j?.message || `Login failed (${r.status})`);
|
||||||
const reason = j?.errors?.[0]?.message || r.statusText;
|
return j?.data || j;
|
||||||
throw new Error(`Failed to query role "Users": ${reason}`);
|
|
||||||
}
|
|
||||||
const id = j?.data?.[0]?.id ?? j?.[0]?.id;
|
|
||||||
if (!id) {
|
|
||||||
throw new Error('Role "Users" not found. Create it in Directus or check the service token permissions.');
|
|
||||||
}
|
|
||||||
return String(id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
if (!API) {
|
if (!DIRECTUS) return bad("Missing DIRECTUS_URL/NEXT_PUBLIC_API_BASE_URL", 500);
|
||||||
return bad("Server misconfiguration: DIRECTUS_URL / NEXT_PUBLIC_API_BASE_URL is not set", 500);
|
if (!SERVICE_TOKEN) return bad("Missing DIRECTUS_TOKEN_ADMIN_REGISTER", 500);
|
||||||
}
|
|
||||||
if (!SERVICE_TOKEN) {
|
const body = await req.json().catch(() => ({} as any));
|
||||||
return bad(
|
const email = String(body?.email ?? "").trim().toLowerCase();
|
||||||
"Server misconfiguration: DIRECTUS_SERVICE_TOKEN / DIRECTUS_TOKEN_ADMIN_REGISTER is not set",
|
const username = String(body?.username ?? "").trim();
|
||||||
500,
|
const password = String(body?.password ?? "").trim();
|
||||||
{ hint: "Set DIRECTUS_TOKEN_ADMIN_REGISTER=<token> (or DIRECTUS_SERVICE_TOKEN) and restart the server." }
|
const confirm = String(body?.confirmPassword ?? body?.confirm ?? "").trim();
|
||||||
);
|
|
||||||
|
if (!email || !username || !password || !confirm) return bad("All fields are required");
|
||||||
|
if (!EMAIL_RE.test(email)) return bad("Enter a valid email address");
|
||||||
|
if (password.length < 8) return bad("Password must be at least 8 characters");
|
||||||
|
if (password !== confirm) return bad("Passwords do not match");
|
||||||
|
|
||||||
|
// Optional pre-check to return a friendly 409 instead of a generic Directus error
|
||||||
|
const existsRes = await fetch(
|
||||||
|
`${DIRECTUS}/users?filter[_or][0][email][_eq]=${encodeURIComponent(email)}` +
|
||||||
|
`&filter[_or][1][username][_eq]=${encodeURIComponent(username)}` +
|
||||||
|
`&fields=id,email,username&limit=1`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${SERVICE_TOKEN}`,
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const existsJson = await existsRes.json().catch(() => ({}));
|
||||||
|
if (Array.isArray(existsJson?.data) && existsJson.data.length > 0) {
|
||||||
|
return bad("Email or username already in use", 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json().catch(() => ({}));
|
// Create user with sane defaults (no provider — Directus defaults to "default")
|
||||||
const username = String(body?.username || "").trim();
|
const createPayload: any = {
|
||||||
const email = String(body?.email || "").trim().toLowerCase(); // optional
|
email,
|
||||||
const password = String(body?.password || "").trim();
|
|
||||||
const first_name = String(body?.first_name || "").trim() || undefined;
|
|
||||||
const last_name = String(body?.last_name || "").trim() || undefined;
|
|
||||||
|
|
||||||
if (!username) return bad("Username is required");
|
|
||||||
if (!password || password.length < 8) return bad("Password must be at least 8 characters");
|
|
||||||
|
|
||||||
// Only accept the "Users" role
|
|
||||||
const roleId = await getUsersRoleId();
|
|
||||||
|
|
||||||
// Create the user in Directus using service token
|
|
||||||
const createPayload: Record<string, any> = {
|
|
||||||
status: "active", // change to "pending" to require email verification
|
|
||||||
role: roleId,
|
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
|
status: "active",
|
||||||
};
|
};
|
||||||
if (email) createPayload.email = email;
|
if (DEFAULT_ROLE) createPayload.role = DEFAULT_ROLE;
|
||||||
if (first_name) createPayload.first_name = first_name;
|
|
||||||
if (last_name) createPayload.last_name = last_name;
|
|
||||||
|
|
||||||
const createRes = await fetch(`${API}/users`, {
|
const createRes = await fetch(`${DIRECTUS}/users`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${SERVICE_TOKEN}`,
|
Authorization: `Bearer ${SERVICE_TOKEN}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(createPayload),
|
body: JSON.stringify(createPayload), // <-- raw payload (system endpoint)
|
||||||
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
const createJson = await createRes.json().catch(() => ({}));
|
|
||||||
|
|
||||||
|
const cj = await createRes.json().catch(() => ({}));
|
||||||
if (!createRes.ok) {
|
if (!createRes.ok) {
|
||||||
const reason =
|
const msg = cj?.errors?.[0]?.message || cj?.message || `User create failed (${createRes.status})`;
|
||||||
createJson?.errors?.[0]?.message ||
|
return bad(msg, createRes.status || 500);
|
||||||
createJson?.error ||
|
|
||||||
createRes.statusText ||
|
|
||||||
"Registration failed";
|
|
||||||
return bad("Registration failed", createRes.status, { debug: reason });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = createJson?.data ?? createJson;
|
// Auto-login (Directus expects "email" even though it's the identifier)
|
||||||
const res = NextResponse.json({
|
const tokens = await directusLogin(email, password);
|
||||||
ok: true,
|
|
||||||
user: { id: user?.id, email: user?.email, username: user?.username },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Optional auto-login after signup
|
const res = NextResponse.json({ ok: true, id: cj?.data?.id || null }, { status: 201 });
|
||||||
if (AUTO_LOGIN && (email || username)) {
|
if (tokens?.access_token) {
|
||||||
try {
|
res.cookies.set("ma_at", tokens.access_token, {
|
||||||
const identifier = email || username;
|
path: "/",
|
||||||
const auth = await loginDirectus(identifier, password);
|
httpOnly: true,
|
||||||
const access = auth?.access_token ?? auth?.data?.access_token;
|
sameSite: "lax",
|
||||||
const expiresSec = auth?.expires ?? auth?.data?.expires;
|
secure: SECURE,
|
||||||
|
maxAge: 60 * 60, // 1h
|
||||||
if (access) {
|
});
|
||||||
const maxAge =
|
}
|
||||||
typeof expiresSec === "number" ? Math.max(0, Math.floor(expiresSec)) : 60 * 60 * 8;
|
if (tokens?.refresh_token) {
|
||||||
res.cookies.set({
|
res.cookies.set("ma_rt", tokens.refresh_token, {
|
||||||
name: "ma_at",
|
path: "/",
|
||||||
value: access,
|
httpOnly: true,
|
||||||
httpOnly: true,
|
sameSite: "lax",
|
||||||
sameSite: "lax",
|
secure: SECURE,
|
||||||
secure,
|
maxAge: 60 * 60 * 24 * 30, // 30d
|
||||||
path: "/",
|
});
|
||||||
maxAge,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore auto-login failure; user creation succeeded.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return bad(e?.message || "Registration error", e?.status || 500);
|
return bad(e?.message || "Registration error", e?.status || 500);
|
||||||
|
|
|
||||||
17
app/api/debug/meta/route.ts
Normal file
17
app/api/debug/meta/route.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
// app/api/debug/meta/route.ts
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
export async function GET() {
|
||||||
|
let buildId = null;
|
||||||
|
try {
|
||||||
|
buildId = (await fs.readFile(process.cwd() + "/.next/BUILD_ID", "utf8")).trim();
|
||||||
|
} catch {}
|
||||||
|
return NextResponse.json({
|
||||||
|
env: process.env.NEXT_PUBLIC_ENV || null,
|
||||||
|
nodeEnv: process.env.NODE_ENV || null,
|
||||||
|
buildId,
|
||||||
|
image: process.env.IMAGE_TAG || null,
|
||||||
|
commit: process.env.COMMIT_SHA || null,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import SignIn from "./sign-in";
|
import SignIn from "./sign-in";
|
||||||
|
import { isJwtValid } from "@/lib/jwt";
|
||||||
|
|
||||||
export default async function SignInPage({
|
export default async function SignInPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
|
|
@ -22,7 +23,7 @@ export default async function SignInPage({
|
||||||
if (!reauth) {
|
if (!reauth) {
|
||||||
const ck = await cookies();
|
const ck = await cookies();
|
||||||
const at = ck.get("ma_at")?.value;
|
const at = ck.get("ma_at")?.value;
|
||||||
if (at) redirect("/portal");
|
if (isJwtValid(at)) redirect("/portal");
|
||||||
}
|
}
|
||||||
|
|
||||||
return <SignIn nextPath={nextPath} reauth={reauth} />;
|
return <SignIn nextPath={nextPath} reauth={reauth} />;
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,16 @@ export default function SignIn({ nextPath = "/portal", reauth = false }: Props)
|
||||||
setErr(null);
|
setErr(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
const body = {
|
||||||
|
identifier: identifier.trim(),
|
||||||
|
password,
|
||||||
|
};
|
||||||
|
|
||||||
const res = await fetch("/api/auth/login", {
|
const res = await fetch("/api/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
body: JSON.stringify({ identifier, password }),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
const txt = await res.text();
|
const txt = await res.text();
|
||||||
|
|
@ -34,7 +39,10 @@ export default function SignIn({ nextPath = "/portal", reauth = false }: Props)
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(j?.error || j?.message || `Sign-in failed (${res.status})`);
|
// surface server-provided message when available
|
||||||
|
const msg =
|
||||||
|
j?.error || j?.message || (res.status === 401 ? "Invalid credentials." : `Sign-in failed (${res.status})`);
|
||||||
|
throw new Error(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If this sign-in is being used as a re-auth step, set a short-lived 'recent auth' marker.
|
// If this sign-in is being used as a re-auth step, set a short-lived 'recent auth' marker.
|
||||||
|
|
@ -56,6 +64,8 @@ export default function SignIn({ nextPath = "/portal", reauth = false }: Props)
|
||||||
[identifier, password, nextPath, router, reauth]
|
[identifier, password, nextPath, router, reauth]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const createHref = `/auth/sign-up?next=${encodeURIComponent(nextPath)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-md rounded-lg border p-6 space-y-4">
|
<div className="mx-auto max-w-md rounded-lg border p-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -116,14 +126,13 @@ export default function SignIn({ nextPath = "/portal", reauth = false }: Props)
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{!reauth && (
|
{/* Always show a sign-up path, even on reauth, to avoid dead-ends for first-time visitors */}
|
||||||
<div className="mt-2 text-center text-sm">
|
<div className="mt-2 text-center text-sm">
|
||||||
<span className="opacity-70">New here?</span>{" "}
|
<span className="opacity-70">{reauth ? "Don't have an account?" : "New here?"}</span>{" "}
|
||||||
<a className="underline" href="/auth/sign-up">
|
<a className="underline" href={createHref}>
|
||||||
Create an account
|
Create an account
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import SignUp from "./sign-up";
|
import SignUp from "./sign-up";
|
||||||
|
import { isJwtValid } from "@/lib/jwt";
|
||||||
|
|
||||||
export default async function SignUpPage({
|
export default async function SignUpPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
|
|
@ -10,7 +11,7 @@ export default async function SignUpPage({
|
||||||
}) {
|
}) {
|
||||||
const ck = await cookies();
|
const ck = await cookies();
|
||||||
const at = ck.get("ma_at")?.value;
|
const at = ck.get("ma_at")?.value;
|
||||||
if (at) redirect("/portal");
|
if (isJwtValid(at)) redirect("/portal");
|
||||||
|
|
||||||
const sp = searchParams ?? {};
|
const sp = searchParams ?? {};
|
||||||
const nextParam = Array.isArray(sp.next) ? sp.next[0] : sp.next;
|
const nextParam = Array.isArray(sp.next) ? sp.next[0] : sp.next;
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,14 @@ import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
type Props = { nextPath?: string };
|
type Props = { nextPath?: string };
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
export default function SignUp({ nextPath = "/portal" }: Props) {
|
export default function SignUp({ nextPath = "/portal" }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
@ -20,33 +23,33 @@ export default function SignUp({ nextPath = "/portal" }: Props) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErr(null);
|
setErr(null);
|
||||||
|
|
||||||
const u = username.trim();
|
|
||||||
const em = email.trim().toLowerCase();
|
const em = email.trim().toLowerCase();
|
||||||
const pw = password;
|
const un = username.trim();
|
||||||
|
|
||||||
if (!u) {
|
if (!em || !un || !password || !confirmPassword) {
|
||||||
setErr("Username is required.");
|
setErr("All fields are required.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!pw || pw.length < 8) {
|
if (!EMAIL_RE.test(em)) {
|
||||||
|
setErr("Please enter a valid email address.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
setErr("Password must be at least 8 characters.");
|
setErr("Password must be at least 8 characters.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setErr("Passwords do not match.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/auth/register", {
|
const res = await fetch("/api/auth/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
"Content-Type": "application/json",
|
body: JSON.stringify({ email: em, username: un, password, confirmPassword }),
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: u,
|
|
||||||
email: em || undefined,
|
|
||||||
password: pw,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const txt = await res.text();
|
const txt = await res.text();
|
||||||
|
|
@ -59,13 +62,12 @@ export default function SignUp({ nextPath = "/portal" }: Props) {
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const message =
|
const message =
|
||||||
(j?.error && j?.debug ? `${j.error} (${j.debug})` : j?.error) ||
|
j?.error ||
|
||||||
j?.message ||
|
j?.message ||
|
||||||
`Sign-up failed (${res.status})`;
|
(res.status === 409 ? "Email or username already in use." : `Sign-up failed (${res.status})`);
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If AUTO_LOGIN is enabled on the server, the user is already signed in.
|
|
||||||
router.replace(nextPath);
|
router.replace(nextPath);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
@ -74,17 +76,28 @@ export default function SignUp({ nextPath = "/portal" }: Props) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[username, email, password, nextPath, router]
|
[email, username, password, confirmPassword, nextPath, router]
|
||||||
); // <-- make sure this closing ); is present
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-md rounded-lg border p-6">
|
<div className="mx-auto max-w-md rounded-lg border p-6">
|
||||||
<h1 className="mb-1 text-2xl font-semibold">Create Account</h1>
|
<h1 className="mb-1 text-2xl font-semibold">Create Account</h1>
|
||||||
<p className="mb-6 text-sm opacity-70">
|
<p className="mb-6 text-sm opacity-70">Join MakeArmy to manage rigs, settings, and projects.</p>
|
||||||
Join MakerDash to manage rigs, settings, and projects.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form className="space-y-4" onSubmit={onSubmit}>
|
<form className="space-y-4" onSubmit={onSubmit}>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium">Email</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)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-sm font-medium">Username</label>
|
<label className="text-sm font-medium">Username</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -95,20 +108,7 @@ export default function SignUp({ nextPath = "/portal" }: Props) {
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.currentTarget.value)}
|
onChange={(e) => setUsername(e.currentTarget.value)}
|
||||||
required
|
required
|
||||||
/>
|
minLength={3}
|
||||||
</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>
|
||||||
|
|
||||||
|
|
@ -127,7 +127,7 @@ export default function SignUp({ nextPath = "/portal" }: Props) {
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
className="w-full rounded-md border px-3 py-2"
|
className="w-full rounded-md border px-3 py-2"
|
||||||
placeholder="Choose a strong password"
|
placeholder="At least 8 characters"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||||
required
|
required
|
||||||
|
|
@ -135,6 +135,20 @@ export default function SignUp({ nextPath = "/portal" }: Props) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium">Confirm Password</label>
|
||||||
|
<input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="w-full rounded-md border px-3 py-2"
|
||||||
|
placeholder="Re-enter your password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{err && (
|
{err && (
|
||||||
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
{err}
|
{err}
|
||||||
|
|
|
||||||
28
app/page.tsx
28
app/page.tsx
|
|
@ -3,19 +3,35 @@ import { cookies } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import SignIn from "@/app/auth/sign-in/sign-in";
|
import SignIn from "@/app/auth/sign-in/sign-in";
|
||||||
import SignUp from "@/app/auth/sign-up/sign-up";
|
import SignUp from "@/app/auth/sign-up/sign-up";
|
||||||
|
import { isJwtValid } from "@/lib/jwt";
|
||||||
|
|
||||||
export default async function HomePage() {
|
type SearchParams = { [key: string]: string | string[] | undefined };
|
||||||
// If already signed in, go straight to the app
|
|
||||||
|
export default async function HomePage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams?: SearchParams;
|
||||||
|
}) {
|
||||||
|
// If already signed in with a VALID token, go straight to the app
|
||||||
const ck = await cookies();
|
const ck = await cookies();
|
||||||
const at = ck.get("ma_at")?.value;
|
const at = ck.get("ma_at")?.value;
|
||||||
if (at) redirect("/portal");
|
if (isJwtValid(at)) redirect("/portal");
|
||||||
|
|
||||||
|
const reauth = searchParams?.reauth === "1";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-5xl px-4 py-12">
|
<main className="mx-auto max-w-5xl px-4 py-12">
|
||||||
|
{reauth && (
|
||||||
|
<p className="mb-6 rounded-md border bg-yellow-50 p-3 text-sm text-yellow-900">
|
||||||
|
Your session expired. Please sign in again.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="mb-10 text-center">
|
<section className="mb-10 text-center">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">MakeArmy</h1>
|
<h1 className="text-3xl font-bold tracking-tight">MakeArmy</h1>
|
||||||
<p className="mt-2 text-base text-muted-foreground">
|
<p className="mt-2 text-base text-muted-foreground">
|
||||||
Free to use. Manage laser rigs, settings, and projects—all in one place.
|
Free to use. Manage laser rigs, settings, and projects—all in one
|
||||||
|
place.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -29,12 +45,12 @@ export default async function HomePage() {
|
||||||
<div className="rounded-lg border p-6">
|
<div className="rounded-lg border p-6">
|
||||||
<h2 className="mb-3 text-lg font-semibold">Sign in</h2>
|
<h2 className="mb-3 text-lg font-semibold">Sign in</h2>
|
||||||
{/* Uses your existing sign-in component */}
|
{/* Uses your existing sign-in component */}
|
||||||
<SignIn nextPath="/portal" reauth={false} />
|
<SignIn nextPath="/portal" reauth={reauth} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-8 text-center text-xs text-muted-foreground">
|
<section className="mt-8 text-center text-xs text-muted-foreground">
|
||||||
We only use cookies strictly necessary to operate the site (e.g., your sign-in session).
|
This is the production build v0.0.1 - this site is an active BETA. Some features may not be available or work as intended. If you're experiencing issues please report them here: https://forge.makearmy.io/makearmy/makearmy-app/issues PRIVACY: We only use cookies strictly necessary to operate the site (e.g., your sign-in session). We do not store user data or telemetry other than what you provide and never share or sell data to third parties. Ever.
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -38,10 +38,9 @@ export default function CO2GalvoSettingsPage() {
|
||||||
"submission_id",
|
"submission_id",
|
||||||
"setting_title",
|
"setting_title",
|
||||||
"uploader",
|
"uploader",
|
||||||
// owner (M2O) – ensure username is requested
|
"owner",
|
||||||
"owner.id",
|
"owner.id",
|
||||||
"owner.username",
|
"owner.username",
|
||||||
// assets / denorms
|
|
||||||
"photo.id",
|
"photo.id",
|
||||||
"photo.title",
|
"photo.title",
|
||||||
"mat.name",
|
"mat.name",
|
||||||
|
|
|
||||||
15
lib/jwt.ts
Normal file
15
lib/jwt.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// lib/jwt.ts
|
||||||
|
export function jwtExp(token?: string | null): number | null {
|
||||||
|
if (!token) return null;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64").toString("utf8"));
|
||||||
|
return typeof payload?.exp === "number" ? payload.exp : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isJwtValid(token?: string | null): boolean {
|
||||||
|
const exp = jwtExp(token);
|
||||||
|
return !!exp && exp * 1000 > Date.now();
|
||||||
|
}
|
||||||
|
|
@ -83,6 +83,11 @@ import { NextResponse, NextRequest } from "next/server";
|
||||||
const url = req.nextUrl.clone();
|
const url = req.nextUrl.clone();
|
||||||
const { pathname } = url;
|
const { pathname } = url;
|
||||||
|
|
||||||
|
// ── 0) Absolute rule: the homepage must never redirect (no mapping, no gating).
|
||||||
|
if (pathname === "/") {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
// ── 1) Legacy → Portal / Canonical mapping (runs before auth gating)
|
// ── 1) Legacy → Portal / Canonical mapping (runs before auth gating)
|
||||||
const mapped = legacyMap(pathname);
|
const mapped = legacyMap(pathname);
|
||||||
if (mapped && !isSameUrl(req, mapped)) {
|
if (mapped && !isSameUrl(req, mapped)) {
|
||||||
|
|
@ -174,8 +179,8 @@ import { NextResponse, NextRequest } from "next/server";
|
||||||
type MapResult = { pathname: string; query?: Record<string, string> };
|
type MapResult = { pathname: string; query?: Record<string, string> };
|
||||||
|
|
||||||
function legacyMap(pathname: string): MapResult | null {
|
function legacyMap(pathname: string): MapResult | null {
|
||||||
// If we’re already inside the portal, don’t try to remap again.
|
// Never map the homepage, and if we’re already inside the portal, don’t remap again.
|
||||||
if (pathname.startsWith("/portal")) return null;
|
if (pathname === "/" || pathname.startsWith("/portal")) return null;
|
||||||
|
|
||||||
// 1) DETAIL PAGES: map legacy detail URLs straight into the portal with ?id=
|
// 1) DETAIL PAGES: map legacy detail URLs straight into the portal with ?id=
|
||||||
// NOTE: We intentionally DO NOT remap `/lasers/:id` and `/projects/:id`
|
// NOTE: We intentionally DO NOT remap `/lasers/:id` and `/projects/:id`
|
||||||
|
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "ledb",
|
"name": "ledb",
|
||||||
"version": "1.0.0",
|
"version": "0.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ledb",
|
"name": "ledb",
|
||||||
"version": "1.0.0",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.10",
|
"@radix-ui/react-accordion": "^1.2.10",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "ledb",
|
"name": "ledb",
|
||||||
"version": "1.0.0",
|
"version": "0.0.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue