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
+4
View File
@@ -17,6 +17,10 @@ ADMIN_DATABASE_URL=postgresql://admin_runtime:<percent-encoded-admin-runtime-pas
BETTER_AUTH_USER_SECRET=<independent-openssl-rand-base64-32-output> BETTER_AUTH_USER_SECRET=<independent-openssl-rand-base64-32-output>
RESEND_API_KEY=<staging-only-resend-api-key> RESEND_API_KEY=<staging-only-resend-api-key>
RESEND_FROM_EMAIL=Jyotisha Staging <login@staging.jyotisha.chat> RESEND_FROM_EMAIL=Jyotisha Staging <login@staging.jyotisha.chat>
# Optional test-only OTP channel: when set to a 6-digit code, no real email is
# delivered; the login page shows the fixed code so testers can register/login
# without a mailbox. Never set in production. Omit (or empty) to disable.
#IDENTITY_TEST_OTP=123456
ADMIN_EMAILS=<comma-separated-staging-admin-emails> ADMIN_EMAILS=<comma-separated-staging-admin-emails>
EPAY_CONFIG_ENCRYPTION_KEY=<independent-openssl-rand-base64-32-output> EPAY_CONFIG_ENCRYPTION_KEY=<independent-openssl-rand-base64-32-output>
MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY=<independent-openssl-rand-base64-32-output> MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY=<independent-openssl-rand-base64-32-output>
+26 -18
View File
@@ -819,6 +819,8 @@ input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled
.auth-brand { align-items: center; gap: 10px; font-size: 17px; display: none; margin-bottom: var(--space-10); } .auth-brand { align-items: center; gap: 10px; font-size: 17px; display: none; margin-bottom: var(--space-10); }
.auth-panel h1 { font-size: var(--type-display-md); } .auth-panel h1 { font-size: var(--type-display-md); }
.page-intro { line-height: 1.6; margin: var(--space-3) 0 var(--space-8); color: var(--color-ink-secondary); font-size: var(--type-body-md); } .page-intro { line-height: 1.6; margin: var(--space-3) 0 var(--space-8); color: var(--color-ink-secondary); font-size: var(--type-body-md); }
.auth-test-channel { margin: 0 0 var(--space-6); padding: 10px 12px; border: 1px dashed color-mix(in srgb, var(--color-action) 40%, var(--color-border)); border-radius: var(--radius-md); background: var(--color-action-soft); color: var(--color-ink-secondary); line-height: 1.5; font-size: var(--type-caption); }
.auth-test-channel strong { color: var(--color-action); font-family: var(--font-mono); font-size: 15px; font-variant-numeric: tabular-nums; letter-spacing: .12em; }
.stack-form { display: grid; gap: var(--space-3); } .stack-form { display: grid; gap: var(--space-3); }
.stack-form label { font-size: 13px; } .stack-form label { font-size: 13px; }
.stack-form .button-primary { margin-top: var(--space-2); } .stack-form .button-primary { margin-top: var(--space-2); }
@@ -1763,14 +1765,15 @@ input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled
gap: 14px; gap: 14px;
margin: 8px 0 16px; margin: 8px 0 16px;
padding: 18px; padding: 18px;
border: 1px solid var(--border); border: 1px solid var(--color-border);
border-radius: 16px; border-radius: var(--radius-xl);
background: color-mix(in srgb, var(--card) 92%, transparent); background: linear-gradient(180deg, var(--color-canvas-muted) 0%, var(--color-canvas) 100%);
box-shadow: var(--shadow-soft);
} }
.rectification-candidates-heading { display: grid; gap: 5px; } .rectification-candidates-heading { display: grid; gap: 5px; }
.rectification-candidates-heading strong { font-size: 16px; } .rectification-candidates-heading strong { font-size: var(--type-title-sm); font-family: var(--font-display); font-weight: 600; letter-spacing: -.2px; }
.rectification-candidates-heading span, .rectification-candidates-heading span,
.rectification-candidate-support { color: var(--muted-foreground); font-size: 12px; line-height: 1.5; } .rectification-candidate-support { color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.5; }
.rectification-candidate-list { .rectification-candidate-list {
display: grid; display: grid;
width: 100%; width: 100%;
@@ -1784,33 +1787,38 @@ input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled
min-width: 0; min-width: 0;
min-height: 132px; min-height: 132px;
padding: 14px; padding: 14px;
border: 1px solid var(--border); border: 1px solid var(--color-border);
border-radius: 12px; border-radius: var(--radius-lg);
color: var(--foreground); color: var(--color-ink);
background: var(--card); background: var(--color-canvas);
font: inherit; font: inherit;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
transition: border-color 120ms ease-out, background-color 120ms ease-out, transform 120ms ease-out, box-shadow 120ms ease-out;
} }
.rectification-candidate:hover:not(:disabled) { border-color: color-mix(in srgb, var(--primary) 45%, var(--border)); } .rectification-candidate:hover:not(:disabled) {
.rectification-candidate:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } border-color: color-mix(in srgb, var(--color-action) 45%, var(--color-border));
transform: translateY(-1px);
box-shadow: var(--shadow-soft);
}
.rectification-candidate:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
.rectification-candidate:disabled { cursor: default; opacity: 1; } .rectification-candidate:disabled { cursor: default; opacity: 1; }
.rectification-candidate.is-selected { .rectification-candidate.is-selected {
border-color: color-mix(in srgb, var(--primary) 55%, var(--border)); border-color: color-mix(in srgb, var(--color-action) 55%, var(--color-border));
background: color-mix(in srgb, var(--primary) 7%, var(--card)); background: color-mix(in srgb, var(--color-action-soft) 60%, var(--color-canvas));
} }
.rectification-candidate-time { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .rectification-candidate-time { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.rectification-candidate-time strong { font-size: 22px; font-variant-numeric: tabular-nums; } .rectification-candidate-time strong { font-size: var(--type-title-md); font-family: var(--font-display); font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: -.2px; }
.rectification-candidate-badge { .rectification-candidate-badge {
padding: 2px 7px; padding: 2px 7px;
border-radius: 999px; border-radius: 999px;
color: var(--primary) !important; color: var(--color-action) !important;
background: color-mix(in srgb, var(--primary) 10%, transparent); background: color-mix(in srgb, var(--color-action) 10%, transparent);
font-size: 11px !important; font-size: 11px !important;
font-weight: 650; font-weight: 650;
} }
.rectification-candidate-action { align-self: end; color: var(--primary); font-size: 13px; font-weight: 650; } .rectification-candidate-action { align-self: end; color: var(--color-action); font-size: 13px; font-weight: 650; }
.rectification-saved { margin: 8px 0 16px; color: var(--foreground); font-size: 14px; } .rectification-saved { margin: 8px 0 16px; color: var(--color-ink); font-size: var(--type-body-sm); }
@media (max-width: 640px) { @media (max-width: 640px) {
.rectification-candidates { padding: 14px; } .rectification-candidates { padding: 14px; }
.rectification-candidate-list { .rectification-candidate-list {
+1
View File
@@ -19,6 +19,7 @@ export default async function LoginPage() {
provider={config.provider} provider={config.provider}
passwordEnabled={config.provider === "self-hosted"} passwordEnabled={config.provider === "self-hosted"}
successPath={successPath} successPath={successPath}
testOtp={config.provider === "self-hosted" ? config.testOtp : null}
/> />
); );
} }
@@ -39,11 +39,13 @@ export function EmailOtpLogin({
passwordEnabled = false, passwordEnabled = false,
passwordOnly = false, passwordOnly = false,
successPath = "/", successPath = "/",
testOtp = null,
}: { }: {
provider: AuthProvider; provider: AuthProvider;
passwordEnabled?: boolean; passwordEnabled?: boolean;
passwordOnly?: boolean; passwordOnly?: boolean;
successPath?: "/" | "/admin"; successPath?: "/" | "/admin";
testOtp?: string | null;
}) { }) {
const [mode, setMode] = useState<AuthMode>(passwordOnly ? "password" : "otp"); const [mode, setMode] = useState<AuthMode>(passwordOnly ? "password" : "otp");
const [step, setStep] = useState<AuthStep>("email"); const [step, setStep] = useState<AuthStep>("email");
@@ -57,6 +59,7 @@ export function EmailOtpLogin({
const [notice, setNotice] = useState(""); const [notice, setNotice] = useState("");
const canUsePassword = provider === "self-hosted" && passwordEnabled; const canUsePassword = provider === "self-hosted" && passwordEnabled;
const testMode = testOtp !== null && testOtp !== undefined && testOtp !== "";
const showLoginNavigation = const showLoginNavigation =
step === "email" step === "email"
&& canUsePassword && canUsePassword
@@ -307,6 +310,12 @@ export function EmailOtpLogin({
<h1 id="login-title">{title}</h1> <h1 id="login-title">{title}</h1>
<p className="page-intro">{intro}</p> <p className="page-intro">{intro}</p>
{testMode && (
<p className="auth-test-channel" role="status">
<strong>{testOtp}</strong>
</p>
)}
{showLoginNavigation && ( {showLoginNavigation && (
<nav className="auth-mode-nav" aria-label="登录方式"> <nav className="auth-mode-nav" aria-label="登录方式">
<div className="auth-mode-tabs"> <div className="auth-mode-tabs">
+26 -9
View File
@@ -35,6 +35,7 @@ export function createEmailOtpOptions(
sender: EmailOtpSender, sender: EmailOtpSender,
secret: string, secret: string,
disableSignUp: boolean, disableSignUp: boolean,
testOtp: string | null = null,
): EmailOTPOptions { ): EmailOTPOptions {
return { return {
otpLength: 6, otpLength: 6,
@@ -44,14 +45,30 @@ export function createEmailOtpOptions(
storeOTP: "hashed", storeOTP: "hashed",
disableSignUp, disableSignUp,
rateLimit: { window: 60, max: 3 }, rateLimit: { window: 60, max: 3 },
async sendVerificationOTP({ email, otp, type }) { ...(testOtp
await sender.send({ ? {
email, generateOTP: () => testOtp,
otp, async sendVerificationOTP({ email, otp, type }) {
type, // Test channel: no real email is delivered; the fixed code is
idempotencyKey: otpIdempotencyKey(secret, email, otp, type), // 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, revokeSessionsOnPasswordReset: true,
}, },
plugins: [ plugins: [
emailOTP(createEmailOtpOptions(emailSender, config.userSecret, false)), emailOTP(createEmailOtpOptions(emailSender, config.userSecret, false, config.testOtp)),
twoFactor({ twoFactor({
issuer: "Jyotisha Admin", issuer: "Jyotisha Admin",
twoFactorTable: "two_factors", twoFactorTable: "two_factors",
+7 -4
View File
@@ -8,6 +8,7 @@ import {
type SelfHostedIdentityConfig, type SelfHostedIdentityConfig,
} from "./config.ts"; } from "./config.ts";
import type { EmailOtpSender } from "./contracts.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"; import { ResendEmailOtpSender } from "./email/resend-email-otp-sender.ts";
interface AdminRoleRow { interface AdminRoleRow {
@@ -88,10 +89,12 @@ export function createIdentityAuthServices(
const pool = dependencies.pool ?? createIdentityPool(config.databaseUrl); const pool = dependencies.pool ?? createIdentityPool(config.databaseUrl);
const emailSender = const emailSender =
dependencies.emailSender ?? dependencies.emailSender ??
new ResendEmailOtpSender({ (config.testOtp
apiKey: config.resendApiKey, ? new FakeEmailOtpSender()
from: config.resendFrom, : new ResendEmailOtpSender({
}); apiKey: config.resendApiKey,
from: config.resendFrom,
}));
return { return {
pool, pool,
user: betterAuth( user: betterAuth(
+12
View File
@@ -12,6 +12,8 @@ export interface SelfHostedIdentityConfig {
userSecret: string; userSecret: string;
resendApiKey: string; resendApiKey: string;
resendFrom: string; resendFrom: string;
/** Fixed OTP accepted when IDENTITY_TEST_OTP is set (test channel only). */
testOtp: string | null;
} }
export type IdentityConfig = export type IdentityConfig =
@@ -90,6 +92,15 @@ function readSender(env: IdentityEnvironment): string {
return value; 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( export function readSelfHostedIdentityConfig(
env: IdentityEnvironment, env: IdentityEnvironment,
): SelfHostedIdentityConfig { ): SelfHostedIdentityConfig {
@@ -108,6 +119,7 @@ export function readSelfHostedIdentityConfig(
userSecret, userSecret,
resendApiKey: required(env, "RESEND_API_KEY"), resendApiKey: required(env, "RESEND_API_KEY"),
resendFrom: readSender(env), resendFrom: readSender(env),
testOtp: readTestOtp(env),
}; };
} }
+12 -1
View File
@@ -6,7 +6,7 @@ import { createDatabaseAdminAuthorizer, createIdentityPool } from "../src/module
import { FakeEmailOtpSender } from "../src/modules/identity/email/fake-email-otp-sender.ts"; import { FakeEmailOtpSender } from "../src/modules/identity/email/fake-email-otp-sender.ts";
import type { SelfHostedIdentityConfig } from "../src/modules/identity/config.ts"; import type { SelfHostedIdentityConfig } from "../src/modules/identity/config.ts";
const config: SelfHostedIdentityConfig = { provider: "self-hosted", databaseUrl: "postgresql://identity_runtime:test-password@postgres:5432/jyotisha", userOrigin: "https://staging.jyotisha.chat", adminOrigin: "https://admin.staging.jyotisha.chat", userSecret: "user-secret-that-is-at-least-32-bytes-long", resendApiKey: "re_test", resendFrom: "Jyotisha <login@staging.jyotisha.chat>" }; const config: SelfHostedIdentityConfig = { provider: "self-hosted", databaseUrl: "postgresql://identity_runtime:test-password@postgres:5432/jyotisha", userOrigin: "https://staging.jyotisha.chat", adminOrigin: "https://admin.staging.jyotisha.chat", userSecret: "user-secret-that-is-at-least-32-bytes-long", resendApiKey: "re_test", resendFrom: "Jyotisha <login@staging.jyotisha.chat>", testOtp: null };
const database = { kind: "pool" } as unknown as Pool; const database = { kind: "pool" } as unknown as Pool;
test("Better Auth trusts only the two exact origins and keeps host-only cookies", () => { test("Better Auth trusts only the two exact origins and keeps host-only cookies", () => {
@@ -41,6 +41,17 @@ test("OTP policy remains hashed and bounded", async () => {
assert.match(sender.messages[0].idempotencyKey, /^otp-[0-9a-f]{64}$/); assert.match(sender.messages[0].idempotencyKey, /^otp-[0-9a-f]{64}$/);
}); });
test("test OTP channel pins a fixed code and never sends a real email", async () => {
const sender = new FakeEmailOtpSender();
const options = createEmailOtpOptions(sender, config.userSecret, false, "123456");
assert.equal(options.generateOTP?.({ email: "tester@example.com", type: "sign-in" }), "123456");
const message = sender.messages[0];
await options.sendVerificationOTP({ email: "tester@example.com", otp: "123456", type: "sign-in" });
assert.equal(sender.messages.length, 1);
assert.equal(sender.messages[0].otp, "123456");
assert.match(sender.messages[0].idempotencyKey, /^otp-[0-9a-f]{64}$/);
});
test("identity pool forces the identity search path", async () => { test("identity pool forces the identity search path", async () => {
const pool = createIdentityPool(config.databaseUrl); const pool = createIdentityPool(config.databaseUrl);
try { assert.equal(pool.options.options, "-c search_path=identity,pg_catalog"); } finally { await pool.end(); } try { assert.equal(pool.options.options, "-c search_path=identity,pg_catalog"); } finally { await pool.end(); }
@@ -133,6 +133,7 @@ test("Better Auth supports shared user OTP/password sessions for admins", async
userSecret: "user-secret-that-is-at-least-32-bytes-long", userSecret: "user-secret-that-is-at-least-32-bytes-long",
resendApiKey: "re_test", resendApiKey: "re_test",
resendFrom: "Jyotisha <login@staging.jyotisha.chat>", resendFrom: "Jyotisha <login@staging.jyotisha.chat>",
testOtp: null,
}; };
const previousEnv = new Map( const previousEnv = new Map(
envKeys.map((key) => [key, process.env[key]] as const), envKeys.map((key) => [key, process.env[key]] as const),
+17
View File
@@ -60,3 +60,20 @@ test("localhost may use two distinct HTTP origins", () => {
}); });
assert.equal(config.provider, "self-hosted"); assert.equal(config.provider, "self-hosted");
}); });
test("identity test OTP channel is opt-in and strictly six digits", () => {
const without = readIdentityConfig(selfHostedEnvironment);
assert.equal(without.provider === "self-hosted" ? without.testOtp : "unexpected", null);
const withOtp = readIdentityConfig({ ...selfHostedEnvironment, IDENTITY_TEST_OTP: "123456" });
assert.equal(withOtp.provider === "self-hosted" ? withOtp.testOtp : null, "123456");
assert.throws(
() => readIdentityConfig({ ...selfHostedEnvironment, IDENTITY_TEST_OTP: "12345" }),
/must be a 6-digit/,
);
assert.throws(
() => readIdentityConfig({ ...selfHostedEnvironment, IDENTITY_TEST_OTP: "abcdef" }),
/must be a 6-digit/,
);
});
@@ -11,6 +11,7 @@ const config: SelfHostedIdentityConfig = {
userSecret: "user-secret-that-is-at-least-32-bytes-long", userSecret: "user-secret-that-is-at-least-32-bytes-long",
resendApiKey: "re_test", resendApiKey: "re_test",
resendFrom: "Jyotisha <login@staging.jyotisha.chat>", resendFrom: "Jyotisha <login@staging.jyotisha.chat>",
testOtp: null,
}; };
test("identity host accepts only the two configured origins", () => { test("identity host accepts only the two configured origins", () => {
@@ -245,6 +245,21 @@ test("login UI preserves accessible OTP, password, registration, and reset input
assert.doesNotMatch(component, /hostname\.startsWith|admin\.staging/); assert.doesNotMatch(component, /hostname\.startsWith|admin\.staging/);
assert.match(component, /动态验证码/); assert.match(component, /动态验证码/);
assert.match(component, /恢复码/); assert.match(component, /恢复码/);
assert.match(component, /testOtp\?: string \| null/);
assert.match(component, /测试环境:验证码固定为/);
assert.match(component, /不会发送真实邮件/);
const loginPage = readFileSync(
new URL("../src/app/login/page.tsx", import.meta.url),
"utf8",
);
assert.match(loginPage, /testOtp=\{config\.provider === "self-hosted" \? config\.testOtp : null\}/);
const styles = readFileSync(
new URL("../src/app/globals.css", import.meta.url),
"utf8",
);
assert.match(styles, /\.auth-test-channel \{[\s\S]*border: 1px dashed/);
const route = readFileSync( const route = readFileSync(
new URL("../src/app/api/account/password/route.ts", import.meta.url), new URL("../src/app/api/account/password/route.ts", import.meta.url),