feat(platform): add Discord onboarding and Velocity gate
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
"use server";
|
||||
|
||||
import { hashToken, SESSION_COOKIE_NAME } from "@minecraft-account-manager/auth";
|
||||
import { sessions } from "@minecraft-account-manager/database";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { db } from "@/lib/database";
|
||||
|
||||
export async function logout() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||
|
||||
if (token) {
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(eq(sessions.tokenHash, hashToken(token)));
|
||||
}
|
||||
|
||||
cookieStore.delete(SESSION_COOKIE_NAME);
|
||||
redirect("/");
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { exchangeMagicLink, InvalidLoginCodeError, SESSION_COOKIE_NAME } from "@minecraft-account-manager/auth";
|
||||
import { createAuthRepository, ipObservations, recordEvent } from "@minecraft-account-manager/database";
|
||||
import { getClientIp } from "@minecraft-account-manager/network";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/database";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const code = request.nextUrl.searchParams.get("code") ?? "";
|
||||
|
||||
try {
|
||||
const result = await exchangeMagicLink(code, {
|
||||
repository: createAuthRepository(db),
|
||||
});
|
||||
const ipAddress = getClientIp(request.headers, process.env.TRUST_PROXY === "true");
|
||||
await Promise.all([
|
||||
recordEvent(db, {
|
||||
type: "games.minecraft.account-manager.auth.magic-link.consumed",
|
||||
source: "/web/auth/discord",
|
||||
subject: `user/${result.user.id}`,
|
||||
actorUserId: result.user.id,
|
||||
ipAddress: ipAddress ?? undefined,
|
||||
data: { isNewUser: result.isNewUser },
|
||||
}),
|
||||
ipAddress
|
||||
? db.insert(ipObservations).values({
|
||||
userId: result.user.id,
|
||||
source: "web",
|
||||
ipAddress,
|
||||
classification: "unknown",
|
||||
})
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
|
||||
const destination = result.user.firstName ? "/account" : "/welcome";
|
||||
const response = NextResponse.redirect(new URL(destination, request.url));
|
||||
response.cookies.set(SESSION_COOKIE_NAME, result.sessionToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
expires: result.sessionExpiresAt,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidLoginCodeError) {
|
||||
return NextResponse.redirect(new URL("/auth/error", request.url));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function LoginErrorPage() {
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-canvas px-6 text-ink">
|
||||
<section className="max-w-lg border border-line bg-panel p-8 shadow-[10px_10px_0_var(--color-shadow)]">
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Link rejected</p>
|
||||
<h1 className="mt-4 font-display text-4xl font-black uppercase tracking-tight">That gate key no longer works.</h1>
|
||||
<p className="mt-5 leading-7 text-muted">Login links expire after ten minutes and can only be used once. Return to Discord and run <strong>/account</strong> for a fresh link.</p>
|
||||
<Link className="mt-7 inline-block border border-ink bg-ink px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas" href="/">Back home</Link>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user