diff --git a/.env.local b/.env.local index e4a5573b..7c42d042 100644 --- a/.env.local +++ b/.env.local @@ -4,5 +4,5 @@ NEXT_PUBLIC_API_BASE_URL=https://forms.lasereverything.net # Server-side (used by API routes) DIRECTUS_URL=https://forms.lasereverything.net DIRECTUS_TOKEN_SUBMIT=2uD5w9sFLgPtTtjqfUft8i_pLZRzCSTu -NEXT_PUBLIC_API_BASE_URL=https://forms.lasereverything.net - +DIRECTUS_TOKEN_ADMIN_REGISTER=l_QqNXKpi--Dt-hHDncHyBX0eiHNYZr7 +DIRECTUS_ROLE_MEMBER_ID=296a28bc-60ab-4251-8bef-27f6dfb67948 diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 00000000..f723a128 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,88 @@ +// app/app/api/auth/login/route.ts +import { NextRequest, NextResponse } from "next/server"; +import { setAuthCookies } from "@/lib/auth-cookies"; + +const BASE = process.env.DIRECTUS_URL!; +const ADMIN_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_REGISTER!; // for username→email lookup + +type DirectusList = { data: T[] }; +type LoginResp = { data: { access_token: string; refresh_token: string; expires: number } }; +type MeResp = { data: { id: string; email: string | null; username?: string | null } }; + +async function adminFetch(path: string, init?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, { + ...init, + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": `Bearer ${ADMIN_TOKEN}`, + ...(init?.headers || {}), + }, + }); + const text = await res.text(); + const json = text ? JSON.parse(text) : null; + if (!res.ok) throw new Error(`Directus ${res.status}: ${text || res.statusText}`); + return (json ?? {}) as T; +} + +async function directusLogin(email: string, password: string): Promise { + const res = await fetch(`${BASE}/auth/login`, { + method: "POST", + headers: { "Accept": "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + const text = await res.text(); + const json = text ? JSON.parse(text) : null; + if (!res.ok) throw new Error(`Login failed: ${text || res.statusText}`); + return json.data; +} + +function isEmailLike(s: string) { + return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s); +} + +export async function POST(req: NextRequest) { + try { + const body = await req.json().catch(() => ({})); + const identifier: string = (body?.identifier ?? "").trim(); // username OR email + const password: string = (body?.password ?? "").trim(); + + if (!identifier || !password) { + return NextResponse.json({ error: "Missing identifier or password." }, { status: 400 }); + } + + let email = identifier; + let username: string | undefined = undefined; + + if (!isEmailLike(identifier)) { + // lookup email by username (custom field on directus_users) + const q = `/users?limit=1&filter[username][_eq]=${encodeURIComponent(identifier)}&fields=id,email,username`; + const found = await adminFetch>(q); + const user = found.data?.[0]; + if (!user?.email) return NextResponse.json({ error: "User not found." }, { status: 404 }); + email = user.email; + username = user.username || undefined; + } + + const tokens = await directusLogin(email, password); + + // Fetch /users/me to confirm and obtain username (if email path) + const meRes = await fetch(`${BASE}/users/me?fields=id,email,username`, { + headers: { "Authorization": `Bearer ${tokens.access_token}`, "Accept": "application/json" }, + }); + const meText = await meRes.text(); + const me: MeResp = meText ? JSON.parse(meText) : { data: { id: "", email: null, username: null } }; + + const user = { + id: String(me?.data?.id || ""), + email: me?.data?.email ?? null, + username: (me?.data?.username as string | null) ?? username ?? email.split("@")[0], + }; + + let res = NextResponse.json({ ok: true, user }); + res = setAuthCookies(res, tokens, user); + return res; + } catch (err: any) { + return NextResponse.json({ error: err?.message || "Login failed" }, { status: 401 }); + } +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 00000000..02543b3c --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,9 @@ +// app/app/api/auth/logout/route.ts +import { NextRequest, NextResponse } from "next/server"; +import { clearAuthCookies } from "@/lib/auth-cookies"; + +export async function POST(_req: NextRequest) { + let res = NextResponse.json({ ok: true }); + res = clearAuthCookies(res); + return res; +} diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 00000000..b597b237 --- /dev/null +++ b/app/api/auth/register/route.ts @@ -0,0 +1,114 @@ +// app/app/api/auth/register/route.ts +import { NextRequest, NextResponse } from "next/server"; + +const BASE = process.env.DIRECTUS_URL!; +const ADMIN_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_REGISTER!; +const MEMBER_ROLE_ID = process.env.DIRECTUS_ROLE_MEMBER_ID || ""; // optional override + +if (!BASE) console.warn("[auth/register] Missing DIRECTUS_URL"); +if (!ADMIN_TOKEN) console.warn("[auth/register] Missing DIRECTUS_TOKEN_ADMIN_REGISTER"); + +type DirectusList = { data: T[] }; +type DirectusItem = { data: T }; + +async function dFetch(path: string, init?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, { + ...init, + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": `Bearer ${ADMIN_TOKEN}`, + ...(init?.headers || {}), + }, + }); + const text = await res.text(); + const json = text ? JSON.parse(text) : null; + if (!res.ok) { + throw new Error(`Directus ${res.status}: ${text || res.statusText}`); + } + return (json ?? {}) as T; +} + +async function getMemberRoleId(): Promise { + if (MEMBER_ROLE_ID) return MEMBER_ROLE_ID; + const q = `/roles?limit=1&filter[name][_in]=Member,member&fields=id,name`; + const out = await dFetch>(q); + const hit = out.data?.[0]; + if (!hit?.id) throw new Error("Member role not found. Set DIRECTUS_ROLE_MEMBER_ID."); + return String(hit.id); +} + +async function usernameExists(username: string): Promise { + const q = `/users?limit=1&filter[username][_eq]=${encodeURIComponent(username)}&fields=id`; + const out = await dFetch>(q); + return !!out.data?.length; +} + +async function emailExists(email: string): Promise { + const q = `/users?limit=1&filter[email][_eq]=${encodeURIComponent(email)}&fields=id`; + const out = await dFetch>(q); + return !!out.data?.length; +} + +function isEmailLike(s: string) { + return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s); +} + +export async function POST(req: NextRequest) { + try { + const body = await req.json().catch(() => ({})); + const rawUsername: string = (body?.username ?? "").trim(); + const rawPassword: string = (body?.password ?? "").trim(); + const rawEmail: string = String(body?.email ?? "").trim(); + + if (!rawUsername || rawUsername.length < 3) { + return NextResponse.json({ error: "Username must be at least 3 characters." }, { status: 400 }); + } + if (!rawPassword || rawPassword.length < 8) { + return NextResponse.json({ error: "Password must be at least 8 characters." }, { status: 400 }); + } + if (rawEmail && !isEmailLike(rawEmail)) { + return NextResponse.json({ error: "Email format is invalid." }, { status: 400 }); + } + + if (await usernameExists(rawUsername)) { + return NextResponse.json({ error: "Username is already taken." }, { status: 409 }); + } + + // If no email provided, synthesize a placeholder that’s unique. + let email = rawEmail; + if (!email) { + const base = `${rawUsername.toLowerCase()}+noemail@users.makearmy.local`; + let candidate = base; + let i = 1; + while (await emailExists(candidate)) { + i += 1; + candidate = `${rawUsername.toLowerCase()}+noemail${i}@users.makearmy.local`; + } + email = candidate; + } else { + if (await emailExists(email)) { + return NextResponse.json({ error: "Email already in use." }, { status: 409 }); + } + } + + const role = await getMemberRoleId(); + + // Create the user (status active to allow immediate login) + const create = await dFetch>(`/users`, { + method: "POST", + body: JSON.stringify({ + email, + password: rawPassword, + role, + status: "active", + // Add custom field username on directus_users (unique) + username: rawUsername, + }), + }); + + return NextResponse.json({ ok: true, id: create.data.id, warning: rawEmail ? null : "No email provided. You won't be able to reset your password until you add one." }); + } catch (err: any) { + return NextResponse.json({ error: err?.message || "Register failed" }, { status: 500 }); + } +} diff --git a/app/auth/sign-in/page.tsx b/app/auth/sign-in/page.tsx new file mode 100644 index 00000000..2b3b42d4 --- /dev/null +++ b/app/auth/sign-in/page.tsx @@ -0,0 +1,58 @@ +// app/app/auth/sign-in/page.tsx +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; + +export default function SignInPage() { + const r = useRouter(); + const [identifier, setIdentifier] = useState(""); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + + async function submit() { + setBusy(true); setErr(null); + try { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identifier: identifier.trim(), password }), + }); + const j = await res.json(); + if (!res.ok) throw new Error(j?.error || "Login failed"); + r.replace("/my-rigs"); + } catch (e: any) { + setErr(e?.message || "Error"); + } finally { + setBusy(false); + } + } + + return ( +
+

Sign In

+ + + + {err &&
{err}
} + + + +
+ No account? Create one +
+
+ ); +} diff --git a/app/auth/sign-up/page.tsx b/app/auth/sign-up/page.tsx new file mode 100644 index 00000000..ee38e12a --- /dev/null +++ b/app/auth/sign-up/page.tsx @@ -0,0 +1,91 @@ +// app/app/auth/sign-up/page.tsx +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; + +export default function SignUpPage() { + const r = useRouter(); + const [form, setForm] = useState({ username: "", email: "", password: "", agree: false }); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + const canSubmit = form.username.length >= 3 && form.password.length >= 8 && form.agree && !busy; + + async function submit() { + setBusy(true); setErr(null); + try { + const res = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: form.username.trim(), + email: form.email.trim() || undefined, + password: form.password, + }), + }); + const j = await res.json(); + if (!res.ok) throw new Error(j?.error || "Sign up failed"); + + // Auto-login right after register + const login = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identifier: form.email.trim() || form.username.trim(), password: form.password }), + }); + const lj = await login.json(); + if (!login.ok) throw new Error(lj?.error || "Auto login failed"); + + r.replace("/my-rigs"); // or wherever you want to land + } catch (e: any) { + setErr(e?.message || "Error"); + } finally { + setBusy(false); + } + } + + return ( +
+

Create Account

+ + + + + + + + + + {err &&
{err}
} + + + +
+ Already have an account?{" "} + Sign in +
+
+ ); +} diff --git a/lib/auth-cookies.ts b/lib/auth-cookies.ts new file mode 100644 index 00000000..64f3e496 --- /dev/null +++ b/lib/auth-cookies.ts @@ -0,0 +1,32 @@ +// app/lib/auth-cookies.ts +import { NextResponse } from "next/server"; + +export const ACCESS_COOKIE = "ma_at"; +export const REFRESH_COOKIE = "ma_rt"; +export const USER_COOKIE = "ma_user"; // tiny JSON: {id, username, email?} + +export function setAuthCookies( + res: NextResponse, + tokens: { access_token: string; refresh_token: string; expires?: number }, + user: { id: string; username: string; email?: string | null }, +) { + const maxAge = tokens.expires ? Math.max(0, Math.floor((tokens.expires - Date.now()) / 1000)) : 60 * 60; // default 1h + res.cookies.set(ACCESS_COOKIE, tokens.access_token, { + httpOnly: true, sameSite: "lax", secure: true, path: "/", maxAge, + }); + // keep refresh longer (7d) + res.cookies.set(REFRESH_COOKIE, tokens.refresh_token, { + httpOnly: true, sameSite: "lax", secure: true, path: "/", maxAge: 60 * 60 * 24 * 7, + }); + res.cookies.set(USER_COOKIE, JSON.stringify(user), { + httpOnly: false, sameSite: "lax", secure: true, path: "/", maxAge, + }); + return res; +} + +export function clearAuthCookies(res: NextResponse) { + res.cookies.set(ACCESS_COOKIE, "", { path: "/", maxAge: 0 }); + res.cookies.set(REFRESH_COOKIE, "", { path: "/", maxAge: 0 }); + res.cookies.set(USER_COOKIE, "", { path: "/", maxAge: 0 }); + return res; +}