feat(identity): test-only fixed OTP channel and rectification candidate cards
- IDENTITY_TEST_OTP env: when set to a 6-digit code, no real email is delivered; login page surfaces the fixed code so testers can register and sign in without a mailbox. Opt-in, never set in production. - email-otp-login: test-channel notice with the pinned code. - Rectification candidate list restyled with the project design system (warm canvas, action color, display serif, soft shadow, hover lift). - Regression tests for config parsing, pinned OTP generation, login UI notice, and candidate card styles.
This commit is contained in:
@@ -35,6 +35,7 @@ export function createEmailOtpOptions(
|
||||
sender: EmailOtpSender,
|
||||
secret: string,
|
||||
disableSignUp: boolean,
|
||||
testOtp: string | null = null,
|
||||
): EmailOTPOptions {
|
||||
return {
|
||||
otpLength: 6,
|
||||
@@ -44,14 +45,30 @@ export function createEmailOtpOptions(
|
||||
storeOTP: "hashed",
|
||||
disableSignUp,
|
||||
rateLimit: { window: 60, max: 3 },
|
||||
async sendVerificationOTP({ email, otp, type }) {
|
||||
await sender.send({
|
||||
email,
|
||||
otp,
|
||||
type,
|
||||
idempotencyKey: otpIdempotencyKey(secret, email, otp, type),
|
||||
});
|
||||
},
|
||||
...(testOtp
|
||||
? {
|
||||
generateOTP: () => testOtp,
|
||||
async sendVerificationOTP({ email, otp, type }) {
|
||||
// Test channel: no real email is delivered; the fixed code is
|
||||
// surfaced by the login UI when IDENTITY_TEST_OTP is configured.
|
||||
await sender.send({
|
||||
email,
|
||||
otp,
|
||||
type,
|
||||
idempotencyKey: otpIdempotencyKey(secret, email, otp, type),
|
||||
});
|
||||
},
|
||||
}
|
||||
: {
|
||||
async sendVerificationOTP({ email, otp, type }) {
|
||||
await sender.send({
|
||||
email,
|
||||
otp,
|
||||
type,
|
||||
idempotencyKey: otpIdempotencyKey(secret, email, otp, type),
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,7 +113,7 @@ export function buildAuthOptions({
|
||||
revokeSessionsOnPasswordReset: true,
|
||||
},
|
||||
plugins: [
|
||||
emailOTP(createEmailOtpOptions(emailSender, config.userSecret, false)),
|
||||
emailOTP(createEmailOtpOptions(emailSender, config.userSecret, false, config.testOtp)),
|
||||
twoFactor({
|
||||
issuer: "Jyotisha Admin",
|
||||
twoFactorTable: "two_factors",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type SelfHostedIdentityConfig,
|
||||
} from "./config.ts";
|
||||
import type { EmailOtpSender } from "./contracts.ts";
|
||||
import { FakeEmailOtpSender } from "./email/fake-email-otp-sender.ts";
|
||||
import { ResendEmailOtpSender } from "./email/resend-email-otp-sender.ts";
|
||||
|
||||
interface AdminRoleRow {
|
||||
@@ -88,10 +89,12 @@ export function createIdentityAuthServices(
|
||||
const pool = dependencies.pool ?? createIdentityPool(config.databaseUrl);
|
||||
const emailSender =
|
||||
dependencies.emailSender ??
|
||||
new ResendEmailOtpSender({
|
||||
apiKey: config.resendApiKey,
|
||||
from: config.resendFrom,
|
||||
});
|
||||
(config.testOtp
|
||||
? new FakeEmailOtpSender()
|
||||
: new ResendEmailOtpSender({
|
||||
apiKey: config.resendApiKey,
|
||||
from: config.resendFrom,
|
||||
}));
|
||||
return {
|
||||
pool,
|
||||
user: betterAuth(
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface SelfHostedIdentityConfig {
|
||||
userSecret: string;
|
||||
resendApiKey: string;
|
||||
resendFrom: string;
|
||||
/** Fixed OTP accepted when IDENTITY_TEST_OTP is set (test channel only). */
|
||||
testOtp: string | null;
|
||||
}
|
||||
|
||||
export type IdentityConfig =
|
||||
@@ -90,6 +92,15 @@ function readSender(env: IdentityEnvironment): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function readTestOtp(env: IdentityEnvironment): string | null {
|
||||
const value = env.IDENTITY_TEST_OTP?.trim() || "";
|
||||
if (!value) return null;
|
||||
if (!/^\d{6}$/.test(value)) {
|
||||
throw new Error("IDENTITY_TEST_OTP must be a 6-digit code when set");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function readSelfHostedIdentityConfig(
|
||||
env: IdentityEnvironment,
|
||||
): SelfHostedIdentityConfig {
|
||||
@@ -108,6 +119,7 @@ export function readSelfHostedIdentityConfig(
|
||||
userSecret,
|
||||
resendApiKey: required(env, "RESEND_API_KEY"),
|
||||
resendFrom: readSender(env),
|
||||
testOtp: readTestOtp(env),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user