Document Directus production contract and repair auth
This commit is contained in:
parent
0a7ee5ff35
commit
92ce8bce97
32 changed files with 24821 additions and 65 deletions
|
|
@ -1,13 +1,12 @@
|
|||
// app/api/auth/register/route.ts
|
||||
import { NextResponse } from "next/server";
|
||||
import { rateLimit, requestIp } from "@/lib/rate-limit";
|
||||
import {
|
||||
createDirectusUser,
|
||||
directusUserExists,
|
||||
loginDirectus,
|
||||
} from "@/lib/directus";
|
||||
|
||||
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_ADMIN_TOKEN ||
|
||||
process.env.DIRECTUS_TOKEN_ADMIN_REGISTER || "";
|
||||
const DEFAULT_ROLE = process.env.DIRECTUS_DEFAULT_ROLE || undefined;
|
||||
const SECURE = process.env.NODE_ENV === "production";
|
||||
|
||||
function bad(message: string, status = 400) {
|
||||
|
|
@ -15,16 +14,18 @@ function bad(message: string, status = 400) {
|
|||
}
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
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",
|
||||
function isConflict(error: any): boolean {
|
||||
const code = error?.detail?.errors?.[0]?.extensions?.code;
|
||||
const message = error?.detail?.errors?.[0]?.message || error?.message || "";
|
||||
return error?.status === 409 || code === "RECORD_NOT_UNIQUE" || /unique|already exists/i.test(message);
|
||||
}
|
||||
|
||||
function logDirectusFailure(stage: string, error: any) {
|
||||
console.error(`[auth/register] Directus ${stage} failed`, {
|
||||
status: error?.status,
|
||||
code: error?.detail?.errors?.[0]?.extensions?.code,
|
||||
message: error?.detail?.errors?.[0]?.message || error?.message,
|
||||
});
|
||||
const j = await r.json().catch(() => ({}));
|
||||
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) {
|
||||
|
|
@ -32,9 +33,6 @@ export async function POST(req: Request) {
|
|||
if (!rateLimit(`register:${requestIp(req)}`, 5, 60 * 60_000)) {
|
||||
return bad("Too many registration attempts. Please try again later.", 429);
|
||||
}
|
||||
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();
|
||||
|
|
@ -46,53 +44,35 @@ export async function POST(req: Request) {
|
|||
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",
|
||||
try {
|
||||
if (await directusUserExists(email, username)) {
|
||||
return bad("Email or username already in use", 409);
|
||||
}
|
||||
);
|
||||
const existsJson = await existsRes.json().catch(() => ({}));
|
||||
if (Array.isArray(existsJson?.data) && existsJson.data.length > 0) {
|
||||
return bad("Email or username already in use", 409);
|
||||
} catch (error: any) {
|
||||
logDirectusFailure("duplicate lookup", error);
|
||||
return bad("Sign-up is temporarily unavailable.", 503);
|
||||
}
|
||||
|
||||
// Create user with sane defaults
|
||||
const createPayload: any = {
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
};
|
||||
if (DEFAULT_ROLE) createPayload.role = DEFAULT_ROLE;
|
||||
|
||||
const createRes = await fetch(`${DIRECTUS}/users`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${SERVICE_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(createPayload),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const cj = await createRes.json().catch(() => ({}));
|
||||
if (!createRes.ok) {
|
||||
const msg = cj?.errors?.[0]?.message || cj?.message || `User create failed (${createRes.status})`;
|
||||
return bad(msg, createRes.status || 500);
|
||||
let user: { id: string };
|
||||
try {
|
||||
// Resolves the production Users role through the Registration Bot
|
||||
// policy, then creates an active local-auth account in that role.
|
||||
user = await createDirectusUser({ email, username, password });
|
||||
} catch (error: any) {
|
||||
if (isConflict(error)) return bad("Email or username already in use", 409);
|
||||
logDirectusFailure("user creation", error);
|
||||
return bad("Sign-up is temporarily unavailable.", 503);
|
||||
}
|
||||
|
||||
// Auto-login (email-based; directus expects "email" even though it's an identifier)
|
||||
const tokens = await directusLogin(email, password);
|
||||
let tokens: any;
|
||||
try {
|
||||
tokens = await loginDirectus(email, password);
|
||||
} catch (error: any) {
|
||||
logDirectusFailure("post-registration login", error);
|
||||
return bad("Your account was created, but automatic sign-in failed. Please sign in.", 502);
|
||||
}
|
||||
|
||||
const res = NextResponse.json({ ok: true, id: cj?.data?.id || null }, { status: 201 });
|
||||
const res = NextResponse.json({ ok: true, id: user.id }, { status: 201 });
|
||||
if (tokens?.access_token) {
|
||||
res.cookies.set("ma_at", tokens.access_token, {
|
||||
path: "/",
|
||||
|
|
|
|||
|
|
@ -4,14 +4,28 @@
|
|||
// ⚠️ NOTE: Do NOT import `headers()` / `cookies()` from "next/headers" here.
|
||||
// Next 15's types can make those async and break build-time type checks in shared libs.
|
||||
|
||||
const BASE = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
|
||||
const TOKEN_ADMIN_REGISTER = process.env.DIRECTUS_TOKEN_ADMIN_REGISTER || ""; // server-only
|
||||
const BASE = (
|
||||
process.env.DIRECTUS_URL ||
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ||
|
||||
""
|
||||
).replace(/\/$/, "");
|
||||
// DIRECTUS_TOKEN_ADMIN_REGISTER is the canonical credential for the production
|
||||
// Registration Bot policy. Keep the older aliases as fallbacks so an existing
|
||||
// deployment does not silently lose authentication during migration.
|
||||
const TOKEN_ADMIN_REGISTER =
|
||||
process.env.DIRECTUS_TOKEN_ADMIN_REGISTER ||
|
||||
process.env.DIRECTUS_SERVICE_TOKEN ||
|
||||
process.env.DIRECTUS_ADMIN_TOKEN ||
|
||||
""; // server-only
|
||||
const ROLE_MEMBER_NAME = process.env.DIRECTUS_ROLE_MEMBER_NAME || "Users";
|
||||
const PROJECTS_COLLECTION = process.env.DIRECTUS_PROJECTS_COLLECTION || "projects";
|
||||
|
||||
if (!BASE) console.warn("[directus] Missing DIRECTUS_URL");
|
||||
if (!BASE) console.warn("[directus] Missing DIRECTUS_URL / NEXT_PUBLIC_API_BASE_URL");
|
||||
if (!TOKEN_ADMIN_REGISTER)
|
||||
console.warn("[directus] Missing DIRECTUS_TOKEN_ADMIN_REGISTER (used for registration and username login)");
|
||||
console.warn(
|
||||
"[directus] Missing DIRECTUS_TOKEN_ADMIN_REGISTER (or legacy service-token alias) " +
|
||||
"used for registration and username login"
|
||||
);
|
||||
|
||||
export function bytesFromMB(mb: number) {
|
||||
return Math.round(mb * 1024 * 1024);
|
||||
|
|
@ -119,7 +133,8 @@ export async function dxDELETE<T = any>(path: string, bearer: string): Promise<T
|
|||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function directusAdminFetch<T = any>(path: string, init?: RequestInit): Promise<T> {
|
||||
if (!TOKEN_ADMIN_REGISTER) throw new Error("Missing DIRECTUS_TOKEN_ADMIN_REGISTER");
|
||||
if (!BASE) throw new Error("Missing Directus base URL");
|
||||
if (!TOKEN_ADMIN_REGISTER) throw new Error("Missing Directus registration credential");
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
|
|
@ -231,13 +246,25 @@ export async function createDirectusUser(input: {
|
|||
}
|
||||
|
||||
export async function emailForUsername(username: string): Promise<string | null> {
|
||||
const q = `/users?filter[username][_eq]=${encodeURIComponent(username)}&fields=email&limit=1`;
|
||||
const clean = username.trim();
|
||||
if (!clean) return null;
|
||||
const q = `/users?filter[username][_eq]=${encodeURIComponent(clean)}&fields=email&limit=1`;
|
||||
const { data } = await directusAdminFetch<{ data: Array<{ email?: string }> }>(q);
|
||||
const em = data?.[0]?.email;
|
||||
return em ? String(em) : null;
|
||||
}
|
||||
|
||||
export async function directusUserExists(email: string, username: string): Promise<boolean> {
|
||||
const q =
|
||||
`/users?filter[_or][0][email][_eq]=${encodeURIComponent(email)}` +
|
||||
`&filter[_or][1][username][_eq]=${encodeURIComponent(username)}` +
|
||||
`&fields=id&limit=1`;
|
||||
const { data } = await directusAdminFetch<{ data: Array<{ id: string }> }>(q);
|
||||
return Array.isArray(data) && data.length > 0;
|
||||
}
|
||||
|
||||
export async function loginDirectus(email: string, password: string) {
|
||||
if (!BASE) throw new Error("Missing Directus base URL");
|
||||
const res = await fetch(`${BASE}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue