feat(identity): test-only fixed OTP channel and rectification candidate cards
Staging Backend Quality Gate / validate (push) Has been cancelled
Staging Backend Quality Gate / publish (push) Has been cancelled

- 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:
Jesse
2026-08-10 19:38:27 +08:00
parent ed2294875b
commit 2697d16ef1
12 changed files with 131 additions and 32 deletions
+26 -9
View File
@@ -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",
+7 -4
View File
@@ -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
View File
@@ -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),
};
}