Magic-link sign in UI and APIs

This commit is contained in:
adilallo
2026-04-06 16:37:15 -06:00
parent 331ed40234
commit 7218947df3
74 changed files with 1582 additions and 363 deletions
+20 -32
View File
@@ -37,42 +37,31 @@ export async function fetchAuthSession(): Promise<{
return parseJson(res);
}
export async function requestOtp(email: string): Promise<{ ok: true } | { error: string }> {
const res = await fetch("/api/auth/otp/request", {
export async function requestMagicLink(
email: string,
nextPath?: string,
): Promise<{ ok: true } | { ok: false; error: string; retryAfterMs?: number }> {
const res = await fetch("/api/auth/magic-link/request", {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({ email }),
body: JSON.stringify({
email,
...(nextPath ? { next: nextPath } : {}),
}),
});
const data = await parseJson<{ error?: string }>(res);
const data = await parseJson<{ error?: string; retryAfterMs?: number }>(res);
if (!res.ok) {
return { error: readApiErrorMessage(data) };
return {
ok: false,
error: readApiErrorMessage(data),
retryAfterMs:
typeof data.retryAfterMs === "number" ? data.retryAfterMs : undefined,
};
}
return { ok: true };
}
export async function verifyOtp(
email: string,
code: string,
): Promise<
{ ok: true; user: { id: string; email: string } } | { error: string }
> {
const res = await fetch("/api/auth/otp/verify", {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({ email, code }),
});
const data = await parseJson<{
error?: string;
user?: { id: string; email: string };
}>(res);
if (!res.ok || !data.user) {
return { error: readApiErrorMessage(data) };
}
return { ok: true, user: data.user };
}
export async function logout(): Promise<void> {
await fetch("/api/auth/logout", {
method: "POST",
@@ -91,7 +80,9 @@ export async function fetchDraftFromServer(): Promise<CreateFlowState | null> {
return data.draft.payload as CreateFlowState;
}
export async function saveDraftToServer(state: CreateFlowState): Promise<boolean> {
export async function saveDraftToServer(
state: CreateFlowState,
): Promise<boolean> {
const res = await fetch("/api/drafts/me", {
method: "PUT",
credentials: "include",
@@ -105,10 +96,7 @@ export async function publishRule(input: {
title: string;
summary?: string;
document: Record<string, unknown>;
}): Promise<
| { ok: true; id: string; title: string }
| { error: string }
> {
}): Promise<{ ok: true; id: string; title: string } | { error: string }> {
const res = await fetch("/api/rules", {
method: "POST",
credentials: "include",
+18 -3
View File
@@ -255,12 +255,27 @@ export function normalizeNavigationItemSize(
export function normalizeContentLockupVariant(
value: string | undefined,
defaultValue: "hero" = "hero",
): "hero" | "feature" | "learn" | "ask" | "ask-inverse" | "modal" {
): "hero" | "feature" | "learn" | "ask" | "ask-inverse" | "modal" | "login" {
if (!value) return defaultValue;
const normalized = value.toLowerCase();
const variants = ["hero", "feature", "learn", "ask", "ask-inverse", "modal"];
const variants = [
"hero",
"feature",
"learn",
"ask",
"ask-inverse",
"modal",
"login",
];
if (variants.includes(normalized)) {
return normalized as typeof defaultValue;
return normalized as
| "hero"
| "feature"
| "learn"
| "ask"
| "ask-inverse"
| "modal"
| "login";
}
return defaultValue;
}
+7
View File
@@ -0,0 +1,7 @@
/** Allow only same-origin relative paths for open redirects after auth. */
export function safeInternalPath(next: string | null | undefined): string {
if (!next || !next.startsWith("/") || next.startsWith("//")) {
return "/";
}
return next;
}
+3 -1
View File
@@ -1,6 +1,8 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined };
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
-4
View File
@@ -4,10 +4,6 @@ export function sha256Hex(input: string): string {
return createHash("sha256").update(input, "utf8").digest("hex");
}
export function hashOtpCode(code: string, pepper: string): string {
return sha256Hex(`${pepper}:otp:${code}`);
}
export function hashSessionToken(token: string, pepper: string): string {
return sha256Hex(`${pepper}:session:${token}`);
}
+7 -4
View File
@@ -1,12 +1,15 @@
import nodemailer from "nodemailer";
import { logger } from "../logger";
export async function sendOtpEmail(to: string, code: string): Promise<void> {
export async function sendMagicLinkEmail(
to: string,
verifyUrl: string,
): Promise<void> {
const url = process.env.SMTP_URL;
if (!url) {
if (process.env.NODE_ENV === "development") {
logger.info(`[dev] OTP for ${to}: ${code}`);
logger.info(`[dev] Magic link for ${to}: ${verifyUrl}`);
return;
}
throw new Error("SMTP_URL is not configured");
@@ -18,7 +21,7 @@ export async function sendOtpEmail(to: string, code: string): Promise<void> {
await transporter.sendMail({
from,
to,
subject: "Your Community Rule sign-in code",
text: `Your sign-in code is: ${code}\n\nIt expires in 10 minutes.`,
subject: "Sign in to Community Rule",
text: `Open this link to sign in (it expires in 15 minutes):\n\n${verifyUrl}\n\nIf you did not request this, you can ignore this email.`,
});
}
+21
View File
@@ -0,0 +1,21 @@
import { unstable_noStore as noStore } from "next/cache";
import { isDatabaseConfigured } from "./env";
import { getSessionUser } from "./session";
/**
* Whether the current request has a valid session, for marketing shell SSR.
* Aligns with GET /api/auth/session: no DB → treat as signed out; errors → signed out.
*
* `noStore()` avoids any static/prerender reuse where HTML was built without the request cookie
* but the client still receives `initialSignedIn: true` (hydration mismatch on Log in vs Profile).
*/
export async function getNavAuthSignedIn(): Promise<boolean> {
noStore();
if (!isDatabaseConfigured()) return false;
try {
const user = await getSessionUser();
return user != null;
} catch {
return false;
}
}
+4 -1
View File
@@ -50,7 +50,10 @@ export async function createSessionForUser(
return { token, expiresAt };
}
export async function setSessionCookie(token: string, expiresAt: Date): Promise<void> {
export async function setSessionCookie(
token: string,
expiresAt: Date,
): Promise<void> {
const store = await cookies();
store.set(SESSION_COOKIE_NAME, token, {
httpOnly: true,
+20 -27
View File
@@ -1,14 +1,8 @@
import { z } from "zod";
import { FLOW_STEP_ORDER } from "../../../app/create/utils/flowSteps";
import {
assertPlainJsonValue,
DEFAULT_PLAIN_JSON_LIMITS,
} from "./plainJson";
import { assertPlainJsonValue, DEFAULT_PLAIN_JSON_LIMITS } from "./plainJson";
const flowStepTuple = FLOW_STEP_ORDER as unknown as [
string,
...string[],
];
const flowStepTuple = FLOW_STEP_ORDER as unknown as [string, ...string[]];
const createFlowStepSchema = z.enum(flowStepTuple);
@@ -47,25 +41,24 @@ export const createFlowStateSchema = z
}
});
export const publishRuleBodySchema = z
.object({
title: z
.string()
.max(500)
.transform((s) => s.trim())
.refine((s) => s.length > 0, { message: "title required" }),
summary: z
.union([z.string().max(8000), z.null()])
.optional()
.transform((val) => {
if (val === undefined || val === null) {
return null;
}
const t = val.trim();
return t.length > 0 ? t : null;
}),
document: publishedRuleDocumentSchema,
});
export const publishRuleBodySchema = z.object({
title: z
.string()
.max(500)
.transform((s) => s.trim())
.refine((s) => s.length > 0, { message: "title required" }),
summary: z
.union([z.string().max(8000), z.null()])
.optional()
.transform((val) => {
if (val === undefined || val === null) {
return null;
}
const t = val.trim();
return t.length > 0 ? t : null;
}),
document: publishedRuleDocumentSchema,
});
export type PublishRuleBody = z.infer<typeof publishRuleBodySchema>;