diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 934fc3f4..0b46ccd9 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -1,54 +1,72 @@ // app/api/auth/login/route.ts -import { NextResponse } from "next/server"; -import { emailForUsername, loginDirectus, directusAdminFetch } from "@/lib/directus"; -import { setAuthCookies, type TokenBundle, type PublicUser } from "@/lib/auth-cookies"; +import { NextRequest, NextResponse } from "next/server"; +import { setAuthCookies } from "@/lib/auth-cookies"; -export const runtime = "nodejs"; +const BASE = process.env.DIRECTUS_URL!; +if (!BASE) console.warn("[auth/login] Missing DIRECTUS_URL"); -function bad(msg: string, status = 400) { - return NextResponse.json({ ok: false, error: msg }, { status }); +async function jsonSafe(res: Response) { + const text = await res.text(); + try { return { json: text ? JSON.parse(text) : null, text }; } + catch { return { json: null as any, text }; } } -export async function POST(req: Request) { +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(); + const identity: string = (body.identity || body.usernameOrEmail || "").trim(); + const password: string = String(body.password || ""); - if (!identifier) return bad("Missing identifier"); - if (!password) return bad("Missing password"); - - // 1) Resolve email (Directus login requires email) - let email = identifier.includes("@") ? identifier : null; - if (!email) { - email = await emailForUsername(identifier); - if (!email) return bad("User not found", 404); + if (!identity || !password) { + return NextResponse.json({ error: "Missing identity or password" }, { status: 400 }); } - // 2) Login through Directus - const tokens = (await loginDirectus(email, password)) as TokenBundle; + // Directus login (username OR email works via "email" field for both) + const loginRes = await fetch(`${BASE}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ email: identity, password }), + }); + const { json: loginJson, text: loginText } = await jsonSafe(loginRes); + if (!loginRes.ok) { + const msg = + loginJson?.errors?.[0]?.message || + loginJson?.message || + `Directus login failed: ${loginRes.status} ${loginRes.statusText}`; + return NextResponse.json({ error: msg }, { status: 401 }); + } - // 3) Fetch minimal public user - const { data } = await directusAdminFetch<{ data: Array<{ id: string; email: string; username: string }> }>( - `/users?filter[email][_eq]=${encodeURIComponent(email)}&fields=id,email,username&limit=1` - ); - const userRow = data?.[0]; - if (!userRow) return bad("User not found after login", 404); + const access = loginJson?.data?.access_token || loginJson?.access_token; + const refresh = loginJson?.data?.refresh_token || loginJson?.refresh_token; + if (!access || !refresh) { + return NextResponse.json( + { error: `No tokens returned from Directus: ${loginText?.slice(0, 200) || ""}` }, + { status: 500 } + ); + } - const user: PublicUser = { - id: String(userRow.id), - email: String(userRow.email || ""), - username: String(userRow.username || ""), + // Fetch user profile + const meRes = await fetch(`${BASE}/users/me`, { + headers: { Authorization: `Bearer ${access}`, Accept: "application/json" }, + cache: "no-store", + }); + const { json: meJson } = await jsonSafe(meRes); + if (!meRes.ok) { + return NextResponse.json( + { error: meJson?.errors?.[0]?.message || "Failed to fetch user" }, + { status: 500 } + ); + } + const user = { + id: String(meJson?.data?.id ?? ""), + email: String(meJson?.data?.email ?? ""), + username: String(meJson?.data?.username ?? ""), }; - // 4) Build response and set cookies (mutates in-place) - const res = NextResponse.json<{ ok: boolean; user: PublicUser }>({ - ok: true, - user, - }); - setAuthCookies(res, tokens, user); + let res = NextResponse.json({ ok: true, user }); + res = setAuthCookies(res, { access_token: access, refresh_token: refresh }, user); return res; } catch (err: any) { - return NextResponse.json({ ok: false, error: err?.message || "Login failed" }, { status: 401 }); + return NextResponse.json({ error: err?.message || "Login error" }, { status: 500 }); } } diff --git a/app/auth/sign-in/page.tsx b/app/auth/sign-in/page.tsx index af783714..58809e1d 100644 --- a/app/auth/sign-in/page.tsx +++ b/app/auth/sign-in/page.tsx @@ -1,58 +1,80 @@ -// app/app/auth/sign-in/page.tsx +// app/auth/sign-in/page.tsx "use client"; +import { useRouter, useSearchParams } from "next/navigation"; import { useState } from "react"; -import { useRouter } from "next/navigation"; export default function SignInPage() { - const r = useRouter(); - const [identifier, setIdentifier] = useState(""); + const router = useRouter(); + const search = useSearchParams(); + const nextUrl = search.get("next") || "/my/rigs"; + + const [identity, setIdentity] = useState(""); const [password, setPassword] = useState(""); const [busy, setBusy] = useState(false); - const [err, setErr] = useState(null); - async function submit() { - setBusy(true); setErr(null); + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); try { const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ identifier: identifier.trim(), password }), + credentials: "include", // ensure Set-Cookie is honored everywhere + body: JSON.stringify({ identity, password }), }); - const j = await res.json(); + const j = await res.json().catch(() => null); if (!res.ok) throw new Error(j?.error || "Login failed"); - r.replace("/my/rigs"); - } catch (e: any) { - setErr(e?.message || "Error"); + router.replace(nextUrl); + } catch (err) { + alert((err as Error).message); } finally { setBusy(false); } } return ( -
-

Sign In

- - - - {err &&
{err}
} - - - -
- No account? Create one +
+

Sign in

+
+
+ + setIdentity(e.target.value)} + autoComplete="username" + required + />
+
+ + setPassword(e.target.value)} + autoComplete="current-password" + required + /> +
+ +
+ +

+ Don’t have an account?{" "} + + Sign up + +

); } diff --git a/lib/auth-cookies.ts b/lib/auth-cookies.ts index da4f6fb3..6ff16e6f 100644 --- a/lib/auth-cookies.ts +++ b/lib/auth-cookies.ts @@ -4,7 +4,7 @@ import { NextResponse } from "next/server"; export type TokenBundle = { access_token: string; refresh_token?: string; - /** Directus returns seconds-until-expiration */ + /** seconds until expiration (Directus style) */ expires?: number; }; @@ -14,55 +14,89 @@ export type PublicUser = { username: string; }; -export const ACCESS_COOKIE = "ma_access"; -export const REFRESH_COOKIE = "ma_refresh"; +const ACCESS_COOKIE = "ma_at"; +const REFRESH_COOKIE = "ma_rt"; +const USER_COOKIE = "ma_user"; -/** - * Mutates `res` in-place to set auth cookies. - * Keeps tokens HttpOnly; sets SameSite=Lax; Secure for HTTPS. - */ -export function setAuthCookies( - res: NextResponse, - tokens: TokenBundle, - _user?: PublicUser -): void { - const maxAge = - typeof tokens.expires === "number" ? tokens.expires : 60 * 60 * 12; // 12h default - - if (tokens.access_token) { - res.cookies.set(ACCESS_COOKIE, tokens.access_token, { - httpOnly: true, - sameSite: "lax", - secure: true, - path: "/", - maxAge, - }); - } - - if (tokens.refresh_token) { - // If Directus doesn’t give a separate TTL, just make it longer than access (fallback 30d) - const refreshMaxAge = - typeof tokens.expires === "number" ? tokens.expires * 4 : 60 * 60 * 24 * 30; - - res.cookies.set(REFRESH_COOKIE, tokens.refresh_token, { - httpOnly: true, - sameSite: "lax", - secure: true, - path: "/", - maxAge: refreshMaxAge, - }); +/** Derive cookie maxAge (in seconds) for access token */ +function accessMaxAgeSec(expires?: number) { + // If Directus gave us seconds-until-expiration, use that (clamped) + if (typeof expires === "number" && Number.isFinite(expires)) { + return Math.max(60, Math.min(expires, 60 * 60 * 24)); // 1 min .. 1 day } + // Fallback: 1 hour + return 60 * 60; } -/** Mutates `res` in-place to clear both auth cookies. */ -export function clearAuthCookies(res: NextResponse): void { - const opts = { - httpOnly: true, +/** Refresh token lifetime: default ~30 days if present */ +function refreshMaxAgeSec() { + return 60 * 60 * 24 * 30; +} + +/** Shared secure cookie options (override per cookie when needed) */ +function baseOpts(maxAge: number) { + return { + httpOnly: true as const, sameSite: "lax" as const, secure: true, path: "/", - maxAge: 0, // expire immediately + maxAge, + }; +} + +/** + * Set auth cookies on the provided response. + * Returns the SAME response instance with cookies set (typed generically). + */ +export function setAuthCookies( + res: NextResponse, + tokens: TokenBundle, + user?: PublicUser +): NextResponse { + // Access token (httpOnly) + const atAge = accessMaxAgeSec(tokens.expires); + res.cookies.set(ACCESS_COOKIE, tokens.access_token, baseOpts(atAge)); + + // Refresh token (httpOnly) if present + if (tokens.refresh_token) { + res.cookies.set(REFRESH_COOKIE, tokens.refresh_token, baseOpts(refreshMaxAgeSec())); + } + + // Small readable user stub (NOT httpOnly) so client can reflect UI state if desired + if (user) { + const safeStub = JSON.stringify({ + id: user.id, + username: user.username, + email: user.email, + }); + res.cookies.set(USER_COOKIE, safeStub, { + ...baseOpts(atAge), + httpOnly: false, // readable on client + }); + } + + return res; +} + +/** Clear all auth cookies (returns the SAME response instance) */ +export function clearAuthCookies(res: NextResponse): NextResponse { + const opts = { + httpOnly: true as const, + sameSite: "lax" as const, + secure: true, + path: "/", + maxAge: 0, }; res.cookies.set(ACCESS_COOKIE, "", opts); res.cookies.set(REFRESH_COOKIE, "", opts); + // Also clear public user stub + res.cookies.set(USER_COOKIE, "", { ...opts, httpOnly: false }); + return res; } + +/** (Optional) Simple helpers if you ever want the names elsewhere */ +export const AUTH_COOKIE_KEYS = { + access: ACCESS_COOKIE, + refresh: REFRESH_COOKIE, + user: USER_COOKIE, +}; diff --git a/middleware.ts b/middleware.ts index 66922dc2..58fad6b8 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,26 +1,32 @@ // middleware.ts -import { NextRequest, NextResponse } from "next/server"; +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; -/** - * Protect only /my/* pages. - * If the user has no "ma_at" cookie (Directus access token), redirect to /auth/sign-in - * and preserve the original destination via ?next=... - */ - export function middleware(req: NextRequest) { - const token = req.cookies.get("ma_at")?.value; +export function middleware(req: NextRequest) { + const { pathname, searchParams, origin } = req.nextUrl; - if (token) { - return NextResponse.next(); - } + const isAuthPage = + pathname === "/auth/sign-in" || pathname === "/auth/sign-up"; + const isMyArea = pathname.startsWith("/my/"); - // Not logged in → send to the correct sign-in route - const url = req.nextUrl.clone(); - url.pathname = "/auth/sign-in"; - url.searchParams.set("next", req.nextUrl.pathname + req.nextUrl.search); - return NextResponse.redirect(url); - } + const at = req.cookies.get("ma_at")?.value; - // Only run on /my/* so we don’t interfere with other routes (including /auth/*) - export const config = { - matcher: ["/my/:path*"], - }; + // Gate /my/* + if (isMyArea && !at) { + const dest = new URL("/auth/sign-in", origin); + dest.searchParams.set("next", pathname + (req.nextUrl.search || "")); + return NextResponse.redirect(dest); + } + + // If logged in and on auth pages, send to next or /my/rigs + if (isAuthPage && at) { + const nxt = searchParams.get("next") || "/my/rigs"; + return NextResponse.redirect(new URL(nxt, origin)); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/my/:path*", "/auth/sign-in", "/auth/sign-up"], +};