diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index f3d95725..4f357ed2 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -3,71 +3,72 @@ import { NextRequest, NextResponse } from "next/server"; import { emailForUsername, loginDirectus } from "@/lib/directus"; export const runtime = "nodejs"; - 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) { try { const body = await req.json().catch(() => ({} as any)); + const identifier = + String(body?.identifier ?? body?.email ?? body?.username ?? "").trim(); const password = String(body?.password ?? "").trim(); - let identifier = String( - body?.identifier ?? body?.email ?? body?.username ?? "" - ).trim(); if (!identifier || !password) { return NextResponse.json({ error: "Missing credentials" }, { status: 400 }); } - // Resolve to an email for Directus login: - // - If identifier looks like an email, use it directly. - // - Otherwise treat it as a username and look up the email. - let email = identifier.includes("@") ? identifier : null; - if (!email) { - email = await emailForUsername(identifier); - if (!email) { - return NextResponse.json({ error: "User not found" }, { status: 404 }); + // 1) Try Directus directly with the identifier (email OR username) + // Directus expects the field name "email" for both. + const tryIds: string[] = [identifier]; + + // 2) Fallback: if it doesn’t look like an email, try the canonical email (if any) + if (!identifier.includes("@")) { + try { + 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) - const data = await loginDirectus(email, password); - - const access = - data?.access_token ?? data?.data?.access_token ?? null; - const expiresSec = - data?.expires ?? data?.data?.expires ?? null; - - if (!access) { - return NextResponse.json( - { error: "Invalid response from auth provider" }, - { status: 502 } - ); + let tokens: any = null; + let lastErr: any = null; + for (const id of tryIds) { + try { + tokens = await loginDirectus(id, password); // { access_token, refresh_token, expires? } + if (tokens) break; + } catch (e) { + lastErr = e; + } } + 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 }); - - // Use provider TTL if present, else fallback to 8h - const maxAge = - typeof expiresSec === "number" ? Math.max(0, Math.floor(expiresSec)) : 60 * 60 * 8; - - res.cookies.set({ - name: "ma_at", - value: access, + res.cookies.set("ma_at", tokens.access_token, { + path: "/", httpOnly: true, sameSite: "lax", secure, - path: "/", 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; } catch (err: any) { const message = diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index 037cce4e..9cc98e5b 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -1,89 +1,76 @@ // app/api/auth/register/route.ts import { NextResponse } from "next/server"; -import { loginDirectus } from "@/lib/directus"; -export const runtime = "nodejs"; - -// Base URL (no trailing slash) -const API = (process.env.NEXT_PUBLIC_API_BASE_URL || process.env.DIRECTUS_URL || "").replace(/\/$/, ""); - -/** - * Accept either: - * - DIRECTUS_SERVICE_TOKEN (generic name), or - * - DIRECTUS_TOKEN_ADMIN_REGISTER (your current env) - */ +const DIRECTUS = (process.env.DIRECTUS_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""); const SERVICE_TOKEN = process.env.DIRECTUS_SERVICE_TOKEN || -process.env.DIRECTUS_TOKEN_ADMIN_REGISTER || -""; +process.env.DIRECTUS_ADMIN_TOKEN || +process.env.DIRECTUS_TOKEN_ADMIN_REGISTER || ""; +const DEFAULT_ROLE = process.env.DIRECTUS_DEFAULT_ROLE || undefined; +const SECURE = process.env.NODE_ENV === "production"; -// Auto login right after signup (default: true) -const AUTO_LOGIN = (process.env.SIGNUP_AUTO_LOGIN ?? "1") !== "0"; -const secure = process.env.NODE_ENV === "production"; - -function bad(message: string, status = 400, extra: Record = {}) { - return NextResponse.json({ error: message, ...extra }, { status }); +function bad(message: string, status = 400) { + return NextResponse.json({ error: message }, { status }); } +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -// Resolve the role id for the role named **Users**. No fallbacks. -async function getUsersRoleId(): Promise { - 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`, { - headers: { Authorization: `Bearer ${SERVICE_TOKEN}`, Accept: "application/json" }, - cache: "no-store", +async function directusLogin(email: string, password: string) { + const r = await fetch(`${DIRECTUS}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ email, password }), + cache: "no-store", }); const j = await r.json().catch(() => ({})); - if (!r.ok) { - const reason = j?.errors?.[0]?.message || r.statusText; - 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); + if (!r.ok) throw new Error(j?.errors?.[0]?.message || j?.message || `Login failed (${r.status})`); + return j?.data || j; } export async function POST(req: Request) { try { - if (!API) { - return bad("Server misconfiguration: DIRECTUS_URL / NEXT_PUBLIC_API_BASE_URL is not set", 500); - } - if (!SERVICE_TOKEN) { - return bad( - "Server misconfiguration: DIRECTUS_SERVICE_TOKEN / DIRECTUS_TOKEN_ADMIN_REGISTER is not set", - 500, - { hint: "Set DIRECTUS_TOKEN_ADMIN_REGISTER= (or DIRECTUS_SERVICE_TOKEN) and restart the server." } - ); + if (!DIRECTUS) return bad("Missing DIRECTUS_URL/NEXT_PUBLIC_API_BASE_URL", 500); + if (!SERVICE_TOKEN) return bad("Missing DIRECTUS_SERVICE_TOKEN / admin token", 500); + + const body = await req.json().catch(() => ({} as any)); + const email = String(body?.email ?? "").trim().toLowerCase(); + const username = String(body?.username ?? "").trim(); + const password = String(body?.password ?? "").trim(); + 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(() => ({})); - const username = String(body?.username || "").trim(); - const email = String(body?.email || "").trim().toLowerCase(); // optional - 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 = { - status: "active", // change to "pending" to require email verification - role: roleId, + // Create user with sane defaults + const createPayload: any = { + email, username, password, + status: "active", + provider: "default", }; - if (email) createPayload.email = email; - if (first_name) createPayload.first_name = first_name; - if (last_name) createPayload.last_name = last_name; + if (DEFAULT_ROLE) createPayload.role = DEFAULT_ROLE; - const createRes = await fetch(`${API}/users`, { + const createRes = await fetch(`${DIRECTUS}/users`, { method: "POST", headers: { Authorization: `Bearer ${SERVICE_TOKEN}`, @@ -91,50 +78,37 @@ export async function POST(req: Request) { Accept: "application/json", }, body: JSON.stringify(createPayload), + cache: "no-store", }); - const createJson = await createRes.json().catch(() => ({})); + const cj = await createRes.json().catch(() => ({})); if (!createRes.ok) { - const reason = - createJson?.errors?.[0]?.message || - createJson?.error || - createRes.statusText || - "Registration failed"; - return bad("Registration failed", createRes.status, { debug: reason }); + const msg = cj?.errors?.[0]?.message || cj?.message || `User create failed (${createRes.status})`; + return bad(msg, createRes.status || 500); } - const user = createJson?.data ?? createJson; - const res = NextResponse.json({ - ok: true, - user: { id: user?.id, email: user?.email, username: user?.username }, - }); + // Auto-login (email-based; directus expects "email" even though it's an identifier) + const tokens = await directusLogin(email, password); - // Optional auto-login after signup - if (AUTO_LOGIN && (email || username)) { - try { - const identifier = email || username; - const auth = await loginDirectus(identifier, password); - const access = auth?.access_token ?? auth?.data?.access_token; - const expiresSec = auth?.expires ?? auth?.data?.expires; - - if (access) { - const maxAge = - typeof expiresSec === "number" ? Math.max(0, Math.floor(expiresSec)) : 60 * 60 * 8; - res.cookies.set({ - name: "ma_at", - value: access, - httpOnly: true, - sameSite: "lax", - secure, - path: "/", - maxAge, - }); - } - } catch { - // Ignore auto-login failure; user creation succeeded. - } + const res = NextResponse.json({ ok: true, id: cj?.data?.id || null }, { status: 201 }); + if (tokens?.access_token) { + res.cookies.set("ma_at", tokens.access_token, { + path: "/", + httpOnly: true, + sameSite: "lax", + secure: SECURE, + maxAge: 60 * 60, // 1h + }); + } + if (tokens?.refresh_token) { + res.cookies.set("ma_rt", tokens.refresh_token, { + path: "/", + httpOnly: true, + sameSite: "lax", + secure: SECURE, + maxAge: 60 * 60 * 24 * 30, // 30d + }); } - return res; } catch (e: any) { return bad(e?.message || "Registration error", e?.status || 500); diff --git a/app/auth/sign-up/sign-up.tsx b/app/auth/sign-up/sign-up.tsx index a753bab5..b85e3921 100644 --- a/app/auth/sign-up/sign-up.tsx +++ b/app/auth/sign-up/sign-up.tsx @@ -6,11 +6,14 @@ import { useRouter } from "next/navigation"; type Props = { nextPath?: string }; +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + export default function SignUp({ nextPath = "/portal" }: Props) { const router = useRouter(); - const [username, setUsername] = useState(""); const [email, setEmail] = useState(""); + const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); const [loading, setLoading] = useState(false); const [err, setErr] = useState(null); @@ -20,33 +23,33 @@ export default function SignUp({ nextPath = "/portal" }: Props) { e.preventDefault(); setErr(null); - const u = username.trim(); const em = email.trim().toLowerCase(); - const pw = password; + const un = username.trim(); - if (!u) { - setErr("Username is required."); + if (!em || !un || !password || !confirmPassword) { + setErr("All fields are required."); 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."); return; } + if (password !== confirmPassword) { + setErr("Passwords do not match."); + return; + } 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: u, - email: em || undefined, - password: pw, - }), + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ email: em, username: un, password, confirmPassword }), }); const txt = await res.text(); @@ -59,13 +62,12 @@ export default function SignUp({ nextPath = "/portal" }: Props) { if (!res.ok) { const message = - (j?.error && j?.debug ? `${j.error} (${j.debug})` : j?.error) || + j?.error || 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); } - // If AUTO_LOGIN is enabled on the server, the user is already signed in. router.replace(nextPath); router.refresh(); } catch (e: any) { @@ -74,17 +76,28 @@ export default function SignUp({ nextPath = "/portal" }: Props) { setLoading(false); } }, - [username, email, password, nextPath, router] - ); // <-- make sure this closing ); is present + [email, username, password, confirmPassword, nextPath, router] + ); return (

Create Account

-

- Join MakerDash to manage rigs, settings, and projects. -

+

Join MakeArmy to manage rigs, settings, and projects.

+
+ + setEmail(e.currentTarget.value)} + required + /> +
+
setUsername(e.currentTarget.value)} required - /> -
- -
- - setEmail(e.currentTarget.value)} + minLength={3} />
@@ -127,7 +127,7 @@ export default function SignUp({ nextPath = "/portal" }: Props) { type={showPassword ? "text" : "password"} autoComplete="new-password" className="w-full rounded-md border px-3 py-2" - placeholder="Choose a strong password" + placeholder="At least 8 characters" value={password} onChange={(e) => setPassword(e.currentTarget.value)} required @@ -135,6 +135,20 @@ export default function SignUp({ nextPath = "/portal" }: Props) { />
+
+ + setConfirmPassword(e.currentTarget.value)} + required + minLength={8} + /> +
+ {err && (
{err}