mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
Clicking an invitation link while signed out dropped the visitor on a bare login form, even though most invitees have no account yet and nothing on screen told them to create one. Signed-out visitors now get the invitation itself: who invited them, which workspace/project, which role, and which address it was sent to. The primary call to action follows whether an account already exists for that address — "Create your account" when it does not, "Sign in to accept" when it does. The sign-up path carries the invitation forward, so a new account lands back on the invitation and from there on the shared workspace/project instead of the onboarding wizard: - the register link passes invitationToken, the invited email and a callbackUrl - the register form locks the email to the invited address and shows what is being joined - the verification email round-trips the destination through a sanitized `next` parameter - login and verify-email keep the pending destination in their sign-in links Signing in with a different address than the one invited now explains the mismatch instead of silently redirecting to the dashboard. Callback sanitization moves to lib/safe-redirect.ts so login, register, verify-email and the verification route share one open-redirect guard.
49 lines
1.8 KiB
TypeScript
49 lines
1.8 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { consumeVerificationToken } from '@/lib/email-verification';
|
|
import { rateLimit } from '@/lib/rate-limit';
|
|
import { logError } from '@/lib/logger';
|
|
import { getPublicOrigin } from '@/lib/request-origin';
|
|
import { getSafeCallbackUrl } from '@/lib/safe-redirect';
|
|
|
|
// A raw 32-byte hex token is exactly 64 characters.
|
|
const TOKEN_REGEX = /^[0-9a-f]{64}$/;
|
|
|
|
export async function GET(request: NextRequest) {
|
|
// Redirect targets must be built from the public origin, not `request.url`:
|
|
// behind a reverse proxy the latter is the container-internal address and the
|
|
// user lands on a dead host even though verification succeeded.
|
|
const origin = getPublicOrigin(request);
|
|
const redirectTo = (path: string) => NextResponse.redirect(new URL(path, origin));
|
|
|
|
try {
|
|
// Rate-limit by IP to prevent token enumeration attacks.
|
|
const limited = await rateLimit(request, 'verify-email');
|
|
if (limited) return limited;
|
|
|
|
const token = request.nextUrl.searchParams.get('token');
|
|
|
|
if (!token || !TOKEN_REGEX.test(token.trim())) {
|
|
return redirectTo('/login?error=InvalidVerificationToken');
|
|
}
|
|
|
|
const email = await consumeVerificationToken(token.trim());
|
|
|
|
if (!email) {
|
|
return redirectTo('/login?error=InvalidVerificationToken');
|
|
}
|
|
|
|
// Keep the post-verification destination (e.g. an invitation) if one was carried along.
|
|
const next = getSafeCallbackUrl(request.nextUrl.searchParams.get('next'), {
|
|
origin,
|
|
fallback: '',
|
|
});
|
|
|
|
return redirectTo(
|
|
next ? `/login?verified=true&callbackUrl=${encodeURIComponent(next)}` : '/login?verified=true'
|
|
);
|
|
} catch (err) {
|
|
logError('Email verification error:', err);
|
|
return redirectTo('/login?error=VerificationFailed');
|
|
}
|
|
}
|