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:
@@ -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-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); }
|
||||
.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 label { font-size: 13px; }
|
||||
.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;
|
||||
margin: 8px 0 16px;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
background: color-mix(in srgb, var(--card) 92%, transparent);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
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 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-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 {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
@@ -1784,33 +1787,38 @@ input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled
|
||||
min-width: 0;
|
||||
min-height: 132px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
color: var(--foreground);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-ink);
|
||||
background: var(--color-canvas);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
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:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||
.rectification-candidate:hover:not(:disabled) {
|
||||
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.is-selected {
|
||||
border-color: color-mix(in srgb, var(--primary) 55%, var(--border));
|
||||
background: color-mix(in srgb, var(--primary) 7%, var(--card));
|
||||
border-color: color-mix(in srgb, var(--color-action) 55%, var(--color-border));
|
||||
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 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 {
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
color: var(--primary) !important;
|
||||
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||
color: var(--color-action) !important;
|
||||
background: color-mix(in srgb, var(--color-action) 10%, transparent);
|
||||
font-size: 11px !important;
|
||||
font-weight: 650;
|
||||
}
|
||||
.rectification-candidate-action { align-self: end; color: var(--primary); font-size: 13px; font-weight: 650; }
|
||||
.rectification-saved { margin: 8px 0 16px; color: var(--foreground); font-size: 14px; }
|
||||
.rectification-candidate-action { align-self: end; color: var(--color-action); font-size: 13px; font-weight: 650; }
|
||||
.rectification-saved { margin: 8px 0 16px; color: var(--color-ink); font-size: var(--type-body-sm); }
|
||||
@media (max-width: 640px) {
|
||||
.rectification-candidates { padding: 14px; }
|
||||
.rectification-candidate-list {
|
||||
|
||||
@@ -19,6 +19,7 @@ export default async function LoginPage() {
|
||||
provider={config.provider}
|
||||
passwordEnabled={config.provider === "self-hosted"}
|
||||
successPath={successPath}
|
||||
testOtp={config.provider === "self-hosted" ? config.testOtp : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,11 +39,13 @@ export function EmailOtpLogin({
|
||||
passwordEnabled = false,
|
||||
passwordOnly = false,
|
||||
successPath = "/",
|
||||
testOtp = null,
|
||||
}: {
|
||||
provider: AuthProvider;
|
||||
passwordEnabled?: boolean;
|
||||
passwordOnly?: boolean;
|
||||
successPath?: "/" | "/admin";
|
||||
testOtp?: string | null;
|
||||
}) {
|
||||
const [mode, setMode] = useState<AuthMode>(passwordOnly ? "password" : "otp");
|
||||
const [step, setStep] = useState<AuthStep>("email");
|
||||
@@ -57,6 +59,7 @@ export function EmailOtpLogin({
|
||||
const [notice, setNotice] = useState("");
|
||||
|
||||
const canUsePassword = provider === "self-hosted" && passwordEnabled;
|
||||
const testMode = testOtp !== null && testOtp !== undefined && testOtp !== "";
|
||||
const showLoginNavigation =
|
||||
step === "email"
|
||||
&& canUsePassword
|
||||
@@ -307,6 +310,12 @@ export function EmailOtpLogin({
|
||||
<h1 id="login-title">{title}</h1>
|
||||
<p className="page-intro">{intro}</p>
|
||||
|
||||
{testMode && (
|
||||
<p className="auth-test-channel" role="status">
|
||||
测试环境:验证码固定为 <strong>{testOtp}</strong>,不会发送真实邮件。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showLoginNavigation && (
|
||||
<nav className="auth-mode-nav" aria-label="登录方式">
|
||||
<div className="auth-mode-tabs">
|
||||
|
||||
@@ -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