diff --git a/deploy/.env.staging.identity.example b/deploy/.env.staging.identity.example index be70e398..037b55c6 100644 --- a/deploy/.env.staging.identity.example +++ b/deploy/.env.staging.identity.example @@ -17,6 +17,10 @@ ADMIN_DATABASE_URL=postgresql://admin_runtime: RESEND_API_KEY= RESEND_FROM_EMAIL=Jyotisha Staging +# 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= EPAY_CONFIG_ENCRYPTION_KEY= MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY= diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 7c099f36..9d4335c3 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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 { diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 0570f487..649818a4 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -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} /> ); } diff --git a/frontend/src/components/email-otp-login.tsx b/frontend/src/components/email-otp-login.tsx index 66ccc900..d6c15c41 100644 --- a/frontend/src/components/email-otp-login.tsx +++ b/frontend/src/components/email-otp-login.tsx @@ -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(passwordOnly ? "password" : "otp"); const [step, setStep] = useState("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({

{title}

{intro}

+ {testMode && ( +

+ 测试环境:验证码固定为 {testOtp},不会发送真实邮件。 +

+ )} + {showLoginNavigation && (