parent
30ac27815f
commit
74036bc2ce
3 changed files with 173 additions and 184 deletions
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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<string, any> = {}) {
|
||||
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<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`, {
|
||||
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=<token> (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<string, any> = {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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 (
|
||||
<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>
|
||||
<p className="mb-6 text-sm opacity-70">Join MakeArmy 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">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">
|
||||
<label className="text-sm font-medium">Username</label>
|
||||
<input
|
||||
|
|
@ -95,20 +108,7 @@ export default function SignUp({ nextPath = "/portal" }: Props) {
|
|||
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)}
|
||||
minLength={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -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) {
|
|||
/>
|
||||
</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 && (
|
||||
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{err}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue