Skip to content

Commit d0a03ae

Browse files
update email and verification feedback
1 parent 38f6220 commit d0a03ae

6 files changed

Lines changed: 118 additions & 41 deletions

File tree

src/app/api/auth/magic/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ export async function POST(req: NextRequest) {
4343

4444
await sendMail({
4545
to: email,
46-
subject: "Link your worldhello.io network",
47-
html: `<p>Click to keep your network across devices:</p><p><a href="${link}">Verify &amp; link</a></p><p>You must confirm on the same device that requested this email. Expires in 30 minutes. If you didn't request this, ignore it.</p>`,
46+
subject: "Verify your worldhello.io account",
47+
html: `<p>Click below to verify your account on worldhello.io:</p><p><a href="${link}">Verify my account</a></p><p>Confirm on the same device that requested this email. This proves the network belongs to you — it is not for linking other devices. Expires in 30 minutes. If you didn't request this, ignore it.</p>`,
4848
}).catch((err) => {
4949
console.error("[auth/magic] send failed:", err);
5050
});

src/app/api/auth/verify/route.ts

Lines changed: 23 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NextRequest, NextResponse } from "next/server";
22
import { verifyNonce } from "@/lib/token";
3-
import { peekMagicToken, executeMagicVerify } from "@/lib/magic-verify";
3+
import { executeMagicVerify, magicTokenStatus } from "@/lib/magic-verify";
4+
import type { VerifyFailureReason } from "@/lib/verify-feedback";
45

56
export const runtime = "nodejs";
67

@@ -14,8 +15,10 @@ const COOKIE = "wh_lid";
1415
export async function GET(req: NextRequest) {
1516
const token = req.nextUrl.searchParams.get("token");
1617
const nonce = token ? verifyNonce(token) : null;
17-
if (!nonce || !(await peekMagicToken(nonce))) {
18-
return failRedirect(req);
18+
if (!nonce) return failRedirect(req, "invalid");
19+
const status = await magicTokenStatus(nonce);
20+
if (status !== "valid") {
21+
return failRedirect(req, status === "expired" ? "expired" : "used");
1922
}
2023

2124
const safeToken = escapeHtml(token!);
@@ -34,11 +37,11 @@ export async function GET(req: NextRequest) {
3437
</style>
3538
</head>
3639
<body>
37-
<h1>Link your network</h1>
38-
<p>Confirm on <strong>the same device</strong> that requested this email. Verification keeps your referral stats if you switch browsers or devices later.</p>
40+
<h1>Verify your account</h1>
41+
<p>Confirm on <strong>the same device</strong> that requested this email. This verifies your account — it does not link other devices.</p>
3942
<form method="POST" action="/api/auth/verify">
4043
<input type="hidden" name="token" value="${safeToken}" />
41-
<button type="submit">Verify &amp; link email</button>
44+
<button type="submit">Verify my account</button>
4245
</form>
4346
</body>
4447
</html>`;
@@ -61,51 +64,36 @@ export async function POST(req: NextRequest) {
6164

6265
const nonce = token ? verifyNonce(token) : null;
6366
const deviceLocalId = req.cookies.get(COOKIE)?.value;
64-
if (!nonce || !deviceLocalId) return failRedirect(req);
67+
if (!nonce) return failRedirect(req, "invalid");
68+
if (!deviceLocalId) return failRedirect(req, "no_session");
6569

6670
try {
6771
const result = await executeMagicVerify(nonce, deviceLocalId);
6872
if (result === "ok") {
6973
return NextResponse.redirect(new URL("/?verified=1", req.nextUrl.origin));
7074
}
7175
if (result === "device_mismatch") {
72-
return mismatchPage(req);
76+
return failRedirect(req, "wrong_device");
77+
}
78+
if (result === "token_invalid") {
79+
return failRedirectForToken(req, nonce);
7380
}
7481
} catch (err) {
7582
console.error("[auth/verify] magic verify failed:", err);
83+
return failRedirect(req, "error");
7684
}
7785

78-
return failRedirect(req);
86+
return failRedirect(req, "error");
7987
}
8088

81-
function failRedirect(req: NextRequest) {
82-
return NextResponse.redirect(new URL("/?verify=failed", req.nextUrl.origin));
89+
function failRedirect(req: NextRequest, reason: VerifyFailureReason) {
90+
return NextResponse.redirect(new URL(`/?verify=${reason}`, req.nextUrl.origin));
8391
}
8492

85-
function mismatchPage(req: NextRequest) {
86-
const html = `<!DOCTYPE html>
87-
<html lang="en">
88-
<head>
89-
<meta charset="utf-8" />
90-
<meta name="viewport" content="width=device-width, initial-scale=1" />
91-
<title>Verification failed — worldhello.io</title>
92-
<style>
93-
body { font-family: system-ui, sans-serif; max-width: 28rem; margin: 4rem auto; padding: 0 1rem; color: #e8e8e8; background: #0a0a0f; }
94-
h1 { font-size: 1.25rem; }
95-
p { color: #a0a0b0; line-height: 1.5; }
96-
a { color: #7dd3fc; }
97-
</style>
98-
</head>
99-
<body>
100-
<h1>Wrong device</h1>
101-
<p>Open this link on the <strong>same browser</strong> where you requested the verification email, then try again.</p>
102-
<p><a href="/">Return to worldhello.io</a></p>
103-
</body>
104-
</html>`;
105-
return new NextResponse(html, {
106-
status: 403,
107-
headers: { "content-type": "text/html; charset=utf-8" },
108-
});
93+
async function failRedirectForToken(req: NextRequest, nonce: string) {
94+
const status = await magicTokenStatus(nonce);
95+
if (status === "expired") return failRedirect(req, "expired");
96+
return failRedirect(req, "used");
10997
}
11098

11199
function escapeHtml(s: string): string {

src/app/page.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import App from "@/components/App";
2+
import { parseVerifyFailureReason } from "@/lib/verify-feedback";
23

34
export default async function Home({
45
searchParams,
56
}: {
6-
searchParams: Promise<{ ref?: string; link?: string; verified?: string }>;
7+
searchParams: Promise<{ ref?: string; link?: string; verified?: string; verify?: string }>;
78
}) {
8-
const { ref, link, verified } = await searchParams;
9+
const { ref, link, verified, verify } = await searchParams;
910
return (
1011
<App
1112
refCode={ref ?? null}
1213
linkCode={link ?? null}
1314
emailVerified={verified === "1"}
15+
verifyFailureReason={parseVerifyFailureReason(verify)}
1416
/>
1517
);
1618
}

src/components/App.tsx

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import { useEffect, useRef, useState } from "react";
44
import { initBotId } from "botid/client/core";
55
import { useQueryClient } from "@tanstack/react-query";
66
import { invalidateAfterLinkChange, useRegister, useMe, acceptLinkFromUrl } from "@/lib/queries";
7+
import {
8+
messageForVerifyFailure,
9+
type VerifyFailureReason,
10+
} from "@/lib/verify-feedback";
711
import Header from "./sections/Header";
812
import Hero from "./sections/Hero";
913
import Network from "./sections/Network";
@@ -14,10 +18,12 @@ export default function App({
1418
refCode,
1519
linkCode = null,
1620
emailVerified = false,
21+
verifyFailureReason = null,
1722
}: {
1823
refCode: string | null;
1924
linkCode?: string | null;
2025
emailVerified?: boolean;
26+
verifyFailureReason?: VerifyFailureReason | null;
2127
}) {
2228
useEffect(() => {
2329
// BotID challenge only in production (its client init can fail in private
@@ -33,15 +39,32 @@ export default function App({
3339
const linkAttempted = useRef(false);
3440
const verifyAttempted = useRef(false);
3541
const [linkBanner, setLinkBanner] = useState<"linked" | "failed" | null>(null);
42+
const [verifyBanner, setVerifyBanner] = useState<"verified" | "failed" | null>(null);
43+
const verifyFailureMessage = verifyFailureReason
44+
? messageForVerifyFailure(verifyFailureReason)
45+
: null;
46+
47+
useEffect(() => {
48+
if (emailVerified) setVerifyBanner("verified");
49+
else if (verifyFailureReason) setVerifyBanner("failed");
50+
}, [emailVerified, verifyFailureReason]);
3651

3752
useEffect(() => {
3853
if (!emailVerified || !node || verifyAttempted.current) return;
3954
verifyAttempted.current = true;
4055
invalidateAfterLinkChange(queryClient, node.code);
56+
}, [emailVerified, node, queryClient]);
57+
58+
useEffect(() => {
59+
if (!emailVerified && !verifyFailureReason) return;
4160
const url = new URL(window.location.href);
42-
url.searchParams.delete("verified");
61+
if (emailVerified) url.searchParams.delete("verified");
62+
if (verifyFailureReason) url.searchParams.delete("verify");
4363
window.history.replaceState({}, "", url.pathname + url.search + url.hash);
44-
}, [emailVerified, node, queryClient]);
64+
if (verifyFailureReason) {
65+
document.getElementById("verify")?.scrollIntoView({ behavior: "smooth", block: "center" });
66+
}
67+
}, [emailVerified, verifyFailureReason]);
4568

4669
useEffect(() => {
4770
if (!linkCode || !node || linkAttempted.current) return;
@@ -70,6 +93,20 @@ export default function App({
7093
</p>
7194
</div>
7295
)}
96+
{verifyBanner === "verified" && (
97+
<div className="fixed inset-x-0 top-16 z-50 mx-auto w-full max-w-lg px-4">
98+
<p className="rounded-xl border border-purple/30 bg-purple/10 px-4 py-3 text-center text-sm text-purple">
99+
Email verified — your account is confirmed on this device.
100+
</p>
101+
</div>
102+
)}
103+
{verifyBanner === "failed" && verifyFailureMessage && (
104+
<div className="fixed inset-x-0 top-16 z-50 mx-auto w-full max-w-lg px-4">
105+
<p className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-center text-sm text-red-300">
106+
{verifyFailureMessage}
107+
</p>
108+
</div>
109+
)}
73110
<Header fpLabel={fpLabel} />
74111
<Hero node={node ?? null} me={me ?? null} />
75112
<Network node={node ?? null} me={me ?? null} />

src/lib/magic-verify.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@ import { sql } from "drizzle-orm";
22
import { db } from "@/db";
33

44
export type MagicVerifyResult = "ok" | "token_invalid" | "device_mismatch";
5+
export type MagicTokenStatus = "valid" | "expired" | "absent";
6+
7+
/** Whether a nonce still exists and is unexpired (non-consuming). */
8+
export async function magicTokenStatus(nonce: string): Promise<MagicTokenStatus> {
9+
const r = (await db.execute(sql`
10+
SELECT (expires_at > now()) AS "stillValid"
11+
FROM magic_tokens
12+
WHERE nonce = ${nonce}
13+
LIMIT 1;
14+
`)) as unknown as { rows: { stillValid: boolean }[] };
15+
16+
const row = r.rows?.[0];
17+
if (!row) return "absent";
18+
return row.stillValid ? "valid" : "expired";
19+
}
520

621
/**
722
* Consume a magic-link token and link the requesting device to its email account.

src/lib/verify-feedback.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
export const VERIFY_FAILURE_REASONS = [
2+
"expired",
3+
"used",
4+
"invalid",
5+
"wrong_device",
6+
"no_session",
7+
"error",
8+
] as const;
9+
10+
export type VerifyFailureReason = (typeof VERIFY_FAILURE_REASONS)[number];
11+
12+
export function parseVerifyFailureReason(value: string | undefined): VerifyFailureReason | null {
13+
if (!value || value === "1") return null;
14+
if (value === "failed") return "error";
15+
return VERIFY_FAILURE_REASONS.includes(value as VerifyFailureReason)
16+
? (value as VerifyFailureReason)
17+
: null;
18+
}
19+
20+
export function messageForVerifyFailure(reason: VerifyFailureReason): string {
21+
switch (reason) {
22+
case "expired":
23+
return "This link expired — verification links last 30 minutes. Request a new one below.";
24+
case "used":
25+
return "This link was already used — each link works once. Request a new one below.";
26+
case "invalid":
27+
return "This link is invalid — it may be incomplete or corrupted. Request a new one below.";
28+
case "wrong_device":
29+
return "Wrong browser — open the link on the same device where you requested the verification email.";
30+
case "no_session":
31+
return "No device session found — open the link in the same browser where you requested the email.";
32+
case "error":
33+
return "Something went wrong during verification. Request a new link below.";
34+
}
35+
}

0 commit comments

Comments
 (0)