From 43c0ba66142633c787a48904d349494cb94ae80a Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 7 Jul 2026 20:37:42 +0200 Subject: [PATCH] Add Discord verification option for users without email --- src/actions/link-discord.ts | 55 ++++++++++++ src/actions/register.ts | 23 +++-- src/app/verify/page.tsx | 96 +++++++++++++-------- src/components/auth/discord-verify-form.tsx | 78 +++++++++++++++++ src/components/auth/register-form.tsx | 4 +- src/lib/auth.ts | 57 +++++++++--- 6 files changed, 257 insertions(+), 56 deletions(-) create mode 100644 src/actions/link-discord.ts create mode 100644 src/components/auth/discord-verify-form.tsx diff --git a/src/actions/link-discord.ts b/src/actions/link-discord.ts new file mode 100644 index 00000000..713f303c --- /dev/null +++ b/src/actions/link-discord.ts @@ -0,0 +1,55 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; +import { auth } from "@/lib/auth"; + +export async function linkDiscordId(discordId: string): Promise { + const session = await auth(); + if (!session?.user?.id) return "Niet ingelogd"; + + const userId = Number(session.user.id); + if (!discordId || !/^\d{17,20}$/.test(discordId.trim())) { + return "Ongeldig Discord ID"; + } + + const discordIdClean = discordId.trim(); + + // Check if this Discord ID is already linked to another account. + try { + const existing = await prisma.socialAccounts.findUnique({ + where: { provider_providerId: { provider: "discord", providerId: discordIdClean } }, + select: { userId: true }, + }); + if (existing && Number(existing.userId) !== userId) { + return "Dit Discord ID is al gekoppeld aan een ander account"; + } + } catch { + return "Fout bij controleren Discord ID"; + } + + try { + // Upsert: create or update the social_accounts entry. + await prisma.socialAccounts.upsert({ + where: { provider_providerId: { provider: "discord", providerId: discordIdClean } }, + create: { + userId: BigInt(userId), + provider: "discord", + providerId: discordIdClean, + createdAt: new Date(), + updatedAt: new Date(), + }, + update: { userId: BigInt(userId), updatedAt: new Date() }, + }); + + // Mark user as verified. + await prisma.user.update({ + where: { id: userId }, + data: { mailVerified: "1" }, + }); + + return null; // success + } catch (e) { + console.error("[link-discord] Failed:", (e as Error).message); + return "Fout bij koppelen van Discord account"; + } +} diff --git a/src/actions/register.ts b/src/actions/register.ts index 4fa53963..88773d0d 100644 --- a/src/actions/register.ts +++ b/src/actions/register.ts @@ -16,7 +16,7 @@ const registerSchema = z.object({ .min(3, "Username must be at least 3 characters") .max(25, "Username must be at most 25 characters") .regex(/^[A-Za-z0-9_\-=?!@:.,]+$/, "Username contains invalid characters"), - mail: z.string().email("Enter a valid email address"), + mail: z.string().email("Enter a valid email address").optional().or(z.literal("")), password: z .string() .min(8, "Password must be at least 8 characters") @@ -43,6 +43,7 @@ export async function register(prevState: string | null, formData: FormData): Pr } const { username, mail, password, look } = parsed.data; + const hasEmail = !!mail; const ip = await clientIp(); // Throttle sign-ups per IP (5 per 10 minutes) to curb account spam. @@ -88,7 +89,7 @@ export async function register(prevState: string | null, formData: FormData): Pr data: { username, password: await hashPassword(password), - mail, + mail: hasEmail ? mail : null, accountCreated: now, ipRegister: ip, ipCurrent: ip, @@ -97,14 +98,22 @@ export async function register(prevState: string | null, formData: FormData): Pr select: { id: true }, }); - try { - await sendVerification(created.id, mail); - } catch { - // No-op: account is created; user can request a new link later. + if (hasEmail) { + try { + await sendVerification(created.id, mail); + } catch { + // No-op: account is created; user can request a new link later. + } } } catch { return "Could not create the account (is the username unique?)"; } - redirect("/login?registered=1"); + if (hasEmail) { + redirect("/login?registered=1"); + } else { + const { signIn } = await import("@/lib/auth"); + await signIn("credentials", { username, password, redirect: false }); + redirect("/verify?method=discord"); + } } diff --git a/src/app/verify/page.tsx b/src/app/verify/page.tsx index cab26b28..57fa307b 100644 --- a/src/app/verify/page.tsx +++ b/src/app/verify/page.tsx @@ -2,31 +2,43 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { isValidVerificationToken } from "@/actions/email-verify"; import { ContentCard, EmptyState } from "@/components/public/ui"; +import { DiscordVerifyForm } from "@/components/auth/discord-verify-form"; +import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; -// Reads + writes the live users table — must never be statically rendered. export const dynamic = "force-dynamic"; -type Status = "verified" | "already" | "invalid" | "unavailable"; +type Status = "verified" | "already" | "invalid" | "unavailable" | "discord"; export default async function VerifyPage({ searchParams, }: { - searchParams: Promise<{ token?: string; email?: string }>; + searchParams: Promise<{ token?: string; email?: string; method?: string; linked?: string }>; }) { const t = await getTranslations("pages.verify"); - // Next 16: searchParams is a Promise. - const { token = "", email = "" } = await searchParams; + const { token = "", email = "", method = "", linked = "" } = await searchParams; const normalisedEmail = email.trim().toLowerCase(); let status: Status = "invalid"; + let discordAlreadyLinked = false; - if (normalisedEmail && token) { + if (method === "discord" || linked === "1") { + status = "discord"; + const session = await auth(); + if (session?.user?.id) { + const userId = Number(session.user.id); + try { + const linkedRow = await prisma.socialAccounts.findFirst({ + where: { userId: BigInt(userId), provider: "discord" }, + select: { id: true }, + }); + discordAlreadyLinked = !!linkedRow; + } catch {} + } + } else if (normalisedEmail && token) { const ok = await isValidVerificationToken(normalisedEmail, token); if (ok) { try { - // mail is VARCHAR(500) and not unique in the emulator schema, so match - // on the email and flip any matching unverified account(s). const user = await prisma.user.findFirst({ where: { mail: normalisedEmail }, select: { id: true, mailVerified: true }, @@ -38,55 +50,65 @@ export default async function VerifyPage({ } else { await prisma.user.update({ where: { id: user.id }, - // users.mail_verified is a String flag ('0' / '1'). data: { mailVerified: "1" }, }); status = "verified"; } } catch { - // DB unreachable — don't claim success. status = "unavailable"; } - } else { - status = "invalid"; } } return (
- {status === "verified" ? ( + {status === "verified" && ( -

- {t("verifiedBody")} -

- - {t("goToLogin")} - +

{t("verifiedBody")}

+ {t("goToLogin")}
- ) : status === "already" ? ( + )} + + {status === "already" && ( -

- {t("alreadyBody")} -

- - {t("goToLogin")} - +

{t("alreadyBody")}

+ {t("goToLogin")}
- ) : status === "unavailable" ? ( + )} + + {status === "unavailable" && ( -

- {t("unavailableBody")} -

+

{t("unavailableBody")}

- ) : ( + )} + + {status === "invalid" && ( - - {t("invalidBody")} - + {t("invalidBody")}
- - {t("backToLogin")} - + {t("backToLogin")} +
+
+ )} + + {status === "discord" && ( + + {linked === "1" && ( +
+

Gelinkt!

+

Je Discord account is gekoppeld. Je kunt nu verder.

+
+ )} + {discordAlreadyLinked ? ( +
+

Al geverifieerd!

+

Je Discord account is al gekoppeld aan dit account.

+
+ ) : ( + + )} +
+ Naar home
)} diff --git a/src/components/auth/discord-verify-form.tsx b/src/components/auth/discord-verify-form.tsx new file mode 100644 index 00000000..cf63906c --- /dev/null +++ b/src/components/auth/discord-verify-form.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useState } from "react"; +import { linkDiscordId } from "@/actions/link-discord"; + +export function DiscordVerifyForm() { + const [discordId, setDiscordId] = useState(""); + const [error, setError] = useState(null); + const [isPending, setIsPending] = useState(false); + const [success, setSuccess] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setSuccess(false); + setIsPending(true); + try { + const result = await linkDiscordId(discordId); + if (result === null) { + setSuccess(true); + } else { + setError(result); + } + } catch { + setError("Er is een fout opgetreden"); + } finally { + setIsPending(false); + } + } + + if (success) { + return ( +
+

Geverifieerd!

+

Je Discord account is gekoppeld en je account is geverifieerd.

+
+ ); + } + + return ( +
+ {error && ( +
+ {error} +
+ )} + +

+ Open Discord, ga naar Instellingen → Geavanceerd → Ontwikkelaarsmodus (aan). + Klik met rechts op je eigen naam en kies ID kopiëren. Plak dat hier: +

+ +
+ setDiscordId(e.target.value)} + className="flex-1 focus:ring-0 border-4 rounded text-sm px-3 py-2" + style={{ + backgroundColor: "var(--color-background)", + color: "var(--color-text)", + borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)", + }} + required + /> + +
+
+ ); +} diff --git a/src/components/auth/register-form.tsx b/src/components/auth/register-form.tsx index d994e29a..70befb5a 100644 --- a/src/components/auth/register-form.tsx +++ b/src/components/auth/register-form.tsx @@ -99,10 +99,12 @@ export function RegisterForm({ color: "var(--color-text)", borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)", }} - required /> +

+ Geen e-mail? Je kunt later via Discord verifiëren. +

{/* Password & Confirm */} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 1dcce8af..2a88568b 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -117,24 +117,38 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ callbacks: { async signIn({ user, account }) { if (account?.provider === "credentials") return true; - // OAuth: only allow if a hotel account with this email already exists. + // OAuth: try to match by email first. const email = user.email; - if (!email) return "/login?error=NoEmail"; - try { - const dbUser = await prisma.user.findFirst({ - where: { mail: email }, - select: { id: true }, - }); - return dbUser ? true : "/login?error=NoAccount"; - } catch { - return "/login?error=Unavailable"; + if (email) { + try { + const dbUser = await prisma.user.findFirst({ + where: { mail: email }, + select: { id: true }, + }); + if (dbUser) return true; + } catch { + return "/login?error=Unavailable"; + } } + // If email didn't match, try Discord ID via SocialAccounts. + if (account?.provider === "discord" && account.providerAccountId) { + try { + const linked = await prisma.socialAccounts.findUnique({ + where: { provider_providerId: { provider: "discord", providerId: account.providerAccountId } }, + select: { userId: true }, + }); + if (linked) return true; + } catch { + return "/login?error=Unavailable"; + } + } + return "/login?error=NoAccount"; }, async jwt({ token, user, account }) { if (user && account?.provider === "credentials") { token.rank = (user as { rank?: number }).rank; } else if (user?.email) { - // OAuth: bind the session to the matching hotel account. + // OAuth with email: bind to matching hotel account. try { const dbUser = await prisma.user.findFirst({ where: { mail: user.email }, @@ -149,6 +163,27 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ // leave token as-is on lookup failure } } + // OAuth without email match: try Discord ID via SocialAccounts. + if (!token.sub && account?.provider === "discord" && account.providerAccountId) { + try { + const linked = await prisma.socialAccounts.findUnique({ + where: { provider_providerId: { provider: "discord", providerId: account.providerAccountId } }, + }); + if (linked) { + const dbUser = await prisma.user.findUnique({ + where: { id: Number(linked.userId) }, + select: { id: true, rank: true, username: true }, + }); + if (dbUser) { + token.sub = String(dbUser.id); + token.rank = dbUser.rank; + token.name = dbUser.username; + } + } + } catch { + // leave token as-is on lookup failure + } + } return token; }, session({ session, token }) {