mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Merge pull request #42 from yusufipk/feat/invitation-signup-flow
feat(invitations): guide invited users without an account through sign-up
This commit is contained in:
@@ -94,7 +94,11 @@ function createTransport() {
|
||||
return nodemailer.createTransport({ host, port, secure: port === 465, auth: { user, pass } });
|
||||
}
|
||||
|
||||
export async function sendVerificationEmail(email: string, token: string): Promise<void> {
|
||||
export async function sendVerificationEmail(
|
||||
email: string,
|
||||
token: string,
|
||||
options?: { next?: string }
|
||||
): Promise<void> {
|
||||
const transporter = createTransport();
|
||||
if (!transporter) return;
|
||||
|
||||
@@ -109,7 +113,10 @@ export async function sendVerificationEmail(email: string, token: string): Promi
|
||||
return;
|
||||
}
|
||||
|
||||
const verifyUrl = `${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(token)}`;
|
||||
// `next` survives the round-trip so an invited user lands back on the invitation
|
||||
// (and from there on the shared project) instead of a generic login page.
|
||||
const nextParam = options?.next ? `&next=${encodeURIComponent(options.next)}` : '';
|
||||
const verifyUrl = `${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(token)}${nextParam}`;
|
||||
const from = process.env.SMTP_FROM || process.env.EMAIL_FROM || 'OpenFrame <[email protected]>';
|
||||
|
||||
const html = brandedEmailTemplate(
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { headers } from 'next/headers';
|
||||
import { checkRateLimit, getClientIpFromHeaders } from '@/lib/rate-limit';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
/**
|
||||
* The invitation preview surfaces (`/invitations/accept` and `/register?invitationToken=`)
|
||||
* are reachable without a session and each render costs two database queries, so they are
|
||||
* throttled the same way the emailed verify-email link is.
|
||||
*
|
||||
* Two buckets: a generous per-IP one that bounds enumeration across many tokens, and a
|
||||
* tight per-IP+token one that stops repeated probing of a single invitation.
|
||||
*
|
||||
* Returns false when the caller should skip the lookup entirely.
|
||||
*/
|
||||
export async function isInvitationPreviewAllowed(token: string): Promise<boolean> {
|
||||
const ip = getClientIpFromHeaders(await headers());
|
||||
|
||||
const perIp = await checkRateLimit(`invitation-preview:${ip}`, 'invitation-preview');
|
||||
if (!perIp.allowed) return false;
|
||||
|
||||
// Hash the token so raw invitation secrets never reach the rate_limits table (and stay
|
||||
// within the 256-char key bound).
|
||||
const tokenHash = createHash('sha256').update(token).digest('hex').slice(0, 32);
|
||||
const perToken = await checkRateLimit(
|
||||
`invitation-preview:${ip}:${tokenHash}`,
|
||||
'invitation-preview-token'
|
||||
);
|
||||
|
||||
return perToken.allowed;
|
||||
}
|
||||
@@ -221,6 +221,64 @@ export async function createOrRefreshInvitation(params: {
|
||||
throw new Error('Failed to create invitation after retrying');
|
||||
}
|
||||
|
||||
export interface InvitationPreview {
|
||||
email: string;
|
||||
role: InvitationRole;
|
||||
roleLabel: string;
|
||||
scope: InvitationScope;
|
||||
scopeLabel: string;
|
||||
status: InvitationStatus;
|
||||
/** PENDING but past its expiry — the DB row is only flipped to EXPIRED on acceptance. */
|
||||
isExpired: boolean;
|
||||
inviterName: string;
|
||||
targetName: string | null;
|
||||
/** Whether an account already exists for the invited address. */
|
||||
hasAccount: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public-facing summary of an invitation, safe to render to a signed-out visitor:
|
||||
* the token itself is the secret, and everything here was already in the email we sent
|
||||
* to that address.
|
||||
*/
|
||||
export async function getInvitationPreviewByToken(
|
||||
token: string
|
||||
): Promise<InvitationPreview | null> {
|
||||
const invitation = await db.invitation.findUnique({
|
||||
where: { token },
|
||||
select: {
|
||||
email: true,
|
||||
role: true,
|
||||
scope: true,
|
||||
status: true,
|
||||
expiresAt: true,
|
||||
invitedBy: { select: { name: true } },
|
||||
workspace: { select: { name: true } },
|
||||
project: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!invitation) return null;
|
||||
|
||||
const existingUser = await db.user.findUnique({
|
||||
where: { email: invitation.email },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return {
|
||||
email: invitation.email,
|
||||
role: invitation.role,
|
||||
roleLabel: roleLabel(invitation.role),
|
||||
scope: invitation.scope,
|
||||
scopeLabel: scopeLabel(invitation.scope),
|
||||
status: invitation.status,
|
||||
isExpired: invitation.expiresAt <= new Date(),
|
||||
inviterName: invitation.invitedBy?.name?.trim() || 'A team member',
|
||||
targetName: invitation.workspace?.name ?? invitation.project?.name ?? null,
|
||||
hasAccount: Boolean(existingUser),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getValidInvitationByToken(token: string) {
|
||||
const now = new Date();
|
||||
return db.invitation.findFirst({
|
||||
|
||||
+16
-3
@@ -81,6 +81,11 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
|
||||
|
||||
// Member management
|
||||
'invite-member': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour
|
||||
// Unauthenticated invitation preview (accept landing + invited sign-up). Two buckets,
|
||||
// as with share-unlock: a generous per-IP one that bounds token enumeration, and a
|
||||
// tight per-IP+token one that stops repeated probing of a single invitation.
|
||||
'invitation-preview': { windowMs: 15 * 60 * 1000, maxRequests: 120 }, // 120 per 15 min per IP
|
||||
'invitation-preview-token': { windowMs: 15 * 60 * 1000, maxRequests: 12 }, // 12 per 15 min per IP+token
|
||||
'manage-member': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
|
||||
|
||||
// Mutations (update/delete) — moderate
|
||||
@@ -188,12 +193,20 @@ function isPlausibleIp(value: string): boolean {
|
||||
* do so allows clients to spoof their IP and bypass rate limits.
|
||||
*/
|
||||
export function getClientIp(request: Request): string {
|
||||
return getClientIpFromHeaders(request.headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same resolution as {@link getClientIp}, for callers that only have headers rather than a
|
||||
* Request — server components reading `await headers()`.
|
||||
*/
|
||||
export function getClientIpFromHeaders(headers: Headers): string {
|
||||
const mode = process.env.TRUSTED_PROXY_MODE?.trim().toLowerCase();
|
||||
|
||||
if (mode === 'cloudflare') {
|
||||
// cf-connecting-ip is injected by Cloudflare and cannot be set by clients
|
||||
// when origin access is restricted to Cloudflare's IP ranges.
|
||||
const cfIp = request.headers.get('cf-connecting-ip');
|
||||
const cfIp = headers.get('cf-connecting-ip');
|
||||
if (cfIp && isPlausibleIp(cfIp)) {
|
||||
return cfIp;
|
||||
}
|
||||
@@ -202,11 +215,11 @@ export function getClientIp(request: Request): string {
|
||||
if (mode === 'nginx') {
|
||||
// x-real-ip is set by Nginx's real_ip_header directive (connection-level, not spoofable
|
||||
// by clients when set_real_ip_from is configured for the upstream proxy).
|
||||
const realIp = request.headers.get('x-real-ip');
|
||||
const realIp = headers.get('x-real-ip');
|
||||
if (realIp && isPlausibleIp(realIp)) return realIp;
|
||||
|
||||
// x-forwarded-for last entry added by Nginx when proxy_add_x_forwarded_for is used.
|
||||
const forwardedFor = request.headers.get('x-forwarded-for');
|
||||
const forwardedFor = headers.get('x-forwarded-for');
|
||||
if (forwardedFor) {
|
||||
const entries = forwardedFor.split(',');
|
||||
const last = entries[entries.length - 1].trim();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Reduce an untrusted `callbackUrl`/`next` value to a same-origin relative path.
|
||||
* Anything absolute, cross-origin or unparsable falls back to `fallback`.
|
||||
*
|
||||
* Works on both sides: in the browser the origin defaults to `window.location.origin`
|
||||
* (so next-auth's absolute `result.url` still passes), on the server pass the public origin.
|
||||
*/
|
||||
export function getSafeCallbackUrl(
|
||||
value: string | null | undefined,
|
||||
options?: { origin?: string; fallback?: string }
|
||||
): string {
|
||||
const fallback = options?.fallback ?? '/dashboard';
|
||||
if (!value) return fallback;
|
||||
|
||||
const baseOrigin =
|
||||
options?.origin ??
|
||||
(typeof window === 'undefined' ? 'http://localhost' : window.location.origin);
|
||||
|
||||
try {
|
||||
const parsed = new URL(value, baseOrigin);
|
||||
if (parsed.origin !== baseOrigin) return fallback;
|
||||
const path = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
// The origin check alone is not enough: an attacker can smuggle their own host into
|
||||
// the path of an otherwise same-origin URL. `new URL('https://app.example.com//evil.com')`
|
||||
// has our origin but a pathname of `//evil.com`, which every navigation sink below
|
||||
// resolves as protocol-relative and follows off-site.
|
||||
return isSafeRelativePath(path) ? path : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a path is safe to hand to a navigation sink (`router.push`, `<Link href>`,
|
||||
* `NextResponse.redirect`): rooted at a single `/`, so it can only ever stay on this origin.
|
||||
*
|
||||
* `//evil.com` and `/\evil.com` are protocol-relative — browsers fill in the current
|
||||
* scheme and navigate to `evil.com`.
|
||||
*/
|
||||
export function isSafeRelativePath(value: string): boolean {
|
||||
return value.startsWith('/') && !value.startsWith('//') && !value.startsWith('/\\');
|
||||
}
|
||||
|
||||
/** True when a sanitized path points at the invitation acceptance route. */
|
||||
export function isInvitationCallbackUrl(path: string): boolean {
|
||||
return path.startsWith('/invitations/accept');
|
||||
}
|
||||
Reference in New Issue
Block a user