Magic-link sign in UI and APIs
This commit is contained in:
+20
-32
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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.`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user