Magic-link sign in UI and APIs
This commit is contained in:
+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