diff --git a/.gitignore b/.gitignore index 89c6079f..9db73528 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Editor .idea .kiro +opencode.json # Dependencies node_modules/ diff --git a/client/public/logto.png b/client/public/logto.png new file mode 100644 index 00000000..bf020e38 Binary files /dev/null and b/client/public/logto.png differ diff --git a/client/src/i18n/locales/ar.json b/client/src/i18n/locales/ar.json index 63c3a44e..43c864a5 100644 --- a/client/src/i18n/locales/ar.json +++ b/client/src/i18n/locales/ar.json @@ -202,7 +202,23 @@ "backupCodeRequired": "رمز النسخ الاحتياطي مطلوب", "rememberMe": "تذكرني", "trustDevice": "الجهاز موثوق", - "trustDeviceDays": "أيام الثقة" + "trustDeviceDays": "أيام الثقة", + "brandLine1": "سجّل الدخول لاستخدام", + "brandLine2Suffix": " لإدارة", + "brandLine3": "نطاقاتك", + "via": "عبر", + "toContinue": "للمتابعة", + "continue": "متابعة", + "usernameStepLine1": "أدخل اسم المستخدم", + "usernameStepLine2": "للمتابعة", + "passwordStepLine1": "أدخل", + "passwordStepLine2": "كلمة المرور", + "back": "رجوع", + "encryptionFailed": "فشل تشفير كلمة المرور، حاول مرة أخرى", + "httpWarning": "أنت تستخدم HTTP (وليس HTTPS). سيتم إرسال كلمة المرور كنص عادي، مما لا يساوي. هل تريد المتابعة؟", + "httpWarningTitle": "اتصال غير آمن", + "httpWarningConfirm": "متابعة", + "httpWarningCancel": "إلغاء" }, "dashboard": { "totalDomains": "إجمالي النطاقات", diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 4bdb9382..9fbcadd3 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -194,6 +194,22 @@ "rememberMe": "Angemeldet bleiben", "trustDevice": "Diesem Gerät vertrauen", "trustDeviceDays": "Für {days} Tage angemeldet bleiben", + "brandLine1": "Melden Sie sich an, um", + "brandLine2Suffix": " zur Verwaltung", + "brandLine3": "Ihrer Domains zu nutzen", + "via": "Über", + "toContinue": "um fortzufahren", + "continue": "Weiter", + "usernameStepLine1": "Benutzername eingeben", + "usernameStepLine2": "um fortzufahren", + "passwordStepLine1": "Geben Sie ein", + "passwordStepLine2": "Ihr Passwort", + "back": "Zurück", + "encryptionFailed": "Passwortverschlüsselung fehlgeschlagen, bitte erneut versuchen", + "httpWarning": "Sie verwenden HTTP (nicht HTTPS). Ihr Passwort wird im Klartext übertragen, was unsicher ist. Trotzdem fortfahren?", + "httpWarningTitle": "Unsichere Verbindung", + "httpWarningConfirm": "Fortfahren", + "httpWarningCancel": "Abbrechen", "authCodeRequired": "Bitte Bestätigungscode eingeben", "backupCodeRequired": "Bitte Backup-Code eingeben", "logtoUnavailable": "Logto-Anmeldung ist nicht aktiviert oder unvollständig", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 18ae1530..6c21b299 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -202,7 +202,23 @@ "backupCodeRequired": "Please enter backup code", "rememberMe": "Remember me", "trustDevice": "Trust this device", - "trustDeviceDays": "No login for {days} days" + "trustDeviceDays": "No login for {days} days", + "brandLine1": "Sign in to use", + "brandLine2Suffix": " to manage", + "brandLine3": "your domains", + "via": "Via", + "toContinue": "to continue", + "continue": "Continue", + "usernameStepLine1": "Enter username", + "usernameStepLine2": "to continue", + "passwordStepLine1": "Enter", + "passwordStepLine2": "your password", + "back": "Back", + "encryptionFailed": "Password encryption failed, please try again", + "httpWarning": "You are using HTTP (not HTTPS). Your password will be transmitted in plain text, which is insecure. Continue anyway?", + "httpWarningTitle": "Insecure Connection", + "httpWarningConfirm": "Continue", + "httpWarningCancel": "Cancel" }, "dashboard": { "title": "Dashboard", diff --git a/client/src/i18n/locales/es.json b/client/src/i18n/locales/es.json index 9915e932..cce92c2a 100644 --- a/client/src/i18n/locales/es.json +++ b/client/src/i18n/locales/es.json @@ -873,6 +873,22 @@ "rememberMe": "Mantener sesión iniciada", "trustDevice": "Confiar en este dispositivo", "trustDeviceDays": "Mantener sesión por {days} días", + "brandLine1": "Inicia sesión para", + "brandLine2Suffix": " y gestionar", + "brandLine3": "tus dominios", + "via": "Con", + "toContinue": "para continuar", + "continue": "Continuar", + "usernameStepLine1": "Ingresa tu usuario", + "usernameStepLine2": "para continuar", + "passwordStepLine1": "Ingresa", + "passwordStepLine2": "tu contraseña", + "back": "Atrás", + "encryptionFailed": "Falló el cifrado de la contraseña, inténtalo de nuevo", + "httpWarning": "Estás usando HTTP (no HTTPS). Tu contraseña se enviará en texto plano, lo cual es inseguro. ¿Continuar de todos modos?", + "httpWarningTitle": "Conexión insegura", + "httpWarningConfirm": "Continuar", + "httpWarningCancel": "Cancelar", "authCodeRequired": "Por favor, ingresa el código de autenticación", "backupCodeRequired": "Por favor, ingresa el código de respaldo", "logtoUnavailable": "El inicio de sesión con Logto no está habilitado o está incompleto", diff --git a/client/src/i18n/locales/fr.json b/client/src/i18n/locales/fr.json index ba3682e3..9fbf47b1 100644 --- a/client/src/i18n/locales/fr.json +++ b/client/src/i18n/locales/fr.json @@ -194,6 +194,22 @@ "rememberMe": "Rester connecté", "trustDevice": "Faire confiance à cet appareil", "trustDeviceDays": "Rester connecté pendant {days} jours", + "brandLine1": "Connectez-vous pour", + "brandLine2Suffix": " et gérer", + "brandLine3": "vos domaines", + "via": "Via", + "toContinue": "pour continuer", + "continue": "Continuer", + "usernameStepLine1": "Saisissez votre nom d'utilisateur", + "usernameStepLine2": "pour continuer", + "passwordStepLine1": "Saisissez", + "passwordStepLine2": "votre mot de passe", + "back": "Retour", + "encryptionFailed": "Échec du chiffrement du mot de passe, veuillez réessayer", + "httpWarning": "Vous utilisez HTTP (pas HTTPS). Votre mot de passe sera envoyé en texte clair, ce qui n'est pas sécurisé. Continuer quand même ?", + "httpWarningTitle": "Connexion non sécurisée", + "httpWarningConfirm": "Continuer", + "httpWarningCancel": "Annuler", "authCodeRequired": "Veuillez entrer le code d'authentification", "backupCodeRequired": "Veuillez entrer le code de secours", "logtoUnavailable": "La connexion Logto n'est pas activée ou est incomplète", diff --git a/client/src/i18n/locales/ja.json b/client/src/i18n/locales/ja.json index e5dc1402..a8a9b76e 100644 --- a/client/src/i18n/locales/ja.json +++ b/client/src/i18n/locales/ja.json @@ -903,6 +903,22 @@ "rememberMe": "ログイン状態を保持", "trustDevice": "このデバイスを信頼する", "trustDeviceDays": "{days}日間ログインを保持", + "brandLine1": "ログインして", + "brandLine2Suffix": " を活用して", + "brandLine3": "ドメインを管理", + "via": "経由", + "toContinue": "に進む", + "continue": "続ける", + "usernameStepLine1": "ユーザー名を入力", + "usernameStepLine2": "して続行", + "passwordStepLine1": "入力してください", + "passwordStepLine2": "パスワード", + "back": "戻る", + "encryptionFailed": "パスワードの暗号化に失敗しました。もう一度お試しください", + "httpWarning": "HTTP(非HTTPS)を使用しています。パスワードは平文で送信されます。続行しますか?", + "httpWarningTitle": "安全でない接続", + "httpWarningConfirm": "続行", + "httpWarningCancel": "キャンセル", "authCodeRequired": "認証コードを入力してください", "backupCodeRequired": "バックアップコードを入力してください" }, diff --git a/client/src/i18n/locales/ko.json b/client/src/i18n/locales/ko.json index 350ae551..59df5a3d 100644 --- a/client/src/i18n/locales/ko.json +++ b/client/src/i18n/locales/ko.json @@ -191,6 +191,22 @@ "rememberMe": "로그인 상태 유지", "trustDevice": "이 기기 신뢰", "trustDeviceDays": "{days}일 동안 로그인 유지", + "brandLine1": "로그인하여", + "brandLine2Suffix": " 를 사용해", + "brandLine3": "도메인을 관리하세요", + "via": "통해", + "toContinue": "계속하기", + "continue": "계속", + "usernameStepLine1": "사용자 이름 입력", + "usernameStepLine2": "계속하려면", + "passwordStepLine1": "입력하세요", + "passwordStepLine2": "비밀번호", + "back": "뒤로", + "encryptionFailed": "비밀번호 암호화에 실패했습니다. 다시 시도해 주세요", + "httpWarning": "HTTP(비HTTPS)를 사용 중입니다. 비밀번호가 평문으로 전송됩니다. 계속하시겠습니까?", + "httpWarningTitle": "안전하지 않은 연결", + "httpWarningConfirm": "계속", + "httpWarningCancel": "취소", "authCodeRequired": "인증 코드를 입력하세요", "backupCodeRequired": "백업 코드를 입력하세요", "logtoUnavailable": "Logto 로그인이 활성화되지 않았거나 설정이 완료되지 않았습니다", diff --git a/client/src/i18n/locales/pt.json b/client/src/i18n/locales/pt.json index 6c296276..cf045280 100644 --- a/client/src/i18n/locales/pt.json +++ b/client/src/i18n/locales/pt.json @@ -202,6 +202,22 @@ "rememberMe": "Lembrar de mim", "trustDevice": "Confiar neste dispositivo", "trustDeviceDays": "Sem login por {days} dias", + "brandLine1": "Entre para", + "brandLine2Suffix": " e gerenciar", + "brandLine3": "seus domínios", + "via": "Via", + "toContinue": "para continuar", + "continue": "Continuar", + "usernameStepLine1": "Digite o nome de usuário", + "usernameStepLine2": "para continuar", + "passwordStepLine1": "Digite", + "passwordStepLine2": "sua senha", + "back": "Voltar", + "encryptionFailed": "Falha ao criptografar a senha, tente novamente", + "httpWarning": "Você está usando HTTP (não HTTPS). Sua senha será transmitida em texto plano, o que é inseguro. Continuar mesmo assim?", + "httpWarningTitle": "Conexão insegura", + "httpWarningConfirm": "Continuar", + "httpWarningCancel": "Cancelar", "twoFactorHint": "Continue para concluir a verificação" }, "dashboard": { diff --git a/client/src/i18n/locales/ru.json b/client/src/i18n/locales/ru.json index a6b19850..908b7c26 100644 --- a/client/src/i18n/locales/ru.json +++ b/client/src/i18n/locales/ru.json @@ -202,6 +202,22 @@ "rememberMe": "Запомнить меня", "trustDevice": "Доверять этому устройству", "trustDeviceDays": "Без входа в течение {days} дней", + "brandLine1": "Войдите в", + "brandLine2Suffix": ", чтобы управлять", + "brandLine3": "своими доменами", + "via": "Через", + "toContinue": "для продолжения", + "continue": "Продолжить", + "usernameStepLine1": "Введите имя пользователя", + "usernameStepLine2": "для продолжения", + "passwordStepLine1": "Введите", + "passwordStepLine2": "свой пароль", + "back": "Назад", + "encryptionFailed": "Не удалось зашифровать пароль, попробуйте ещё раз", + "httpWarning": "Вы используете HTTP (не HTTPS). Пароль будет передан в открытом виде, что небезопасно. Продолжить?", + "httpWarningTitle": "Небезопасное соединение", + "httpWarningConfirm": "Продолжить", + "httpWarningCancel": "Отмена", "twoFactorHint": "Продолжите для завершения проверки" }, "dashboard": { diff --git a/client/src/i18n/locales/zh-CN-Mesugaki.json b/client/src/i18n/locales/zh-CN-Mesugaki.json index 814e5d3e..ed5a1bed 100644 --- a/client/src/i18n/locales/zh-CN-Mesugaki.json +++ b/client/src/i18n/locales/zh-CN-Mesugaki.json @@ -594,50 +594,66 @@ }, "login": { "failed": "像杂鱼酱这样一副恶心样的人,除了我,谁还会和你聊天呢?嗯~♡呵呵呵~", - "forgotPassword": "忘记密码?通过邮件重置啦", - "oauthFailed": "OAuth 登录失败,哼", - "oauthMissingParams": "你是从正经渠道来的嘛?怎么少东西", - "oauthSignIn": "用 {provider} 登录~", - "oauthUnavailable": "OAuth 不可用,找管理员去", - "logtoSignIn": "用 Logto 登录~", - "logtoUnavailable": "Logto 不可用", + "forgotPassword": "忘了密码?唉,杂鱼就是靠不住,乖乖走邮箱流程吧", + "oauthFailed": "OAuth 登录失败了?杂鱼连第三方登录都玩不明白吗~", + "oauthMissingParams": "你是从正经渠道来的嘛?怎么少了参数,笨死了", + "oauthSignIn": "用 {provider} 登录~省得你自己输错密码", + "oauthUnavailable": "OAuth 没开呢,去找管理员哭诉吧,杂鱼", + "logtoSignIn": "用 Logto 登录呗,杂鱼会用吗?", + "logtoUnavailable": "Logto 没配置好,杂鱼还是老老实实输密码吧", "password": "密码", - "passwordPlaceholder": "输入密码呗", + "passwordPlaceholder": "密码呢?输快点啦笨蛋", "required": "用户名/邮箱和密码都不填,笨死了", - "resetCodePlaceholder": "输入重置验证码", - "resetCodeSent": "验证码已发送,快去查收", - "resetConfirmFailed": "重置密码失败,再试试?", - "resetEmailPlaceholder": "输入账号邮箱", + "resetCodePlaceholder": "把重置验证码交出来啦", + "resetCodeSent": "验证码发你邮箱了,快去查收,磨蹭什么呢", + "resetConfirmFailed": "重置密码都能失败,杂鱼真是笨得让人无语~", + "resetEmailPlaceholder": "输入账号邮箱,笨蛋", "resetEmailRequired": "邮箱都不填,你是笨蛋吗", - "resetFieldsRequired": "重置信息没填完啦,认真点", - "resetNewPasswordPlaceholder": "输入新密码", + "resetFieldsRequired": "重置信息没填完啦,认真点啊杂鱼", + "resetNewPasswordPlaceholder": "输入新密码,别再忘了啦", "resetPassword": "重置密码", - "resetPasswordSuccess": "密码重置成功,下次别忘啦", - "resetRequestFailed": "发送验证码失败,气死我啦", + "resetPasswordSuccess": "密码重置成功,下次别忘啦,笨蛋", + "resetRequestFailed": "发送验证码都失败,气死我啦", "sendResetCode": "发送验证码", "signIn": "登录", - "subtitle": "DNS 聚合管理平台~", + "subtitle": "DNS 聚合管理平台,杂鱼也能用的那种~", "username": "用户名", "usernameOrEmail": "用户名/邮箱", "usernameOrEmailPlaceholder": "输入用户名或邮箱嘛", - "usernamePlaceholder": "输入用户名啦", - "verify2FA": "验证双因素认证", - "enterAuthCode": "输入验证码", - "useBackupCode": "使用备份码", - "enterBackupCode": "输入备份码", - "useAuthCode": "使用验证码", + "usernamePlaceholder": "用户名呢?别让我等太久啦", + "verify2FA": "双因素验证,防的就是你这种笨蛋", + "enterAuthCode": "把验证码乖乖输进来", + "useBackupCode": "用备份码也行啦,反正你肯定忘了", + "enterBackupCode": "备份码输进来,别磨蹭", + "useAuthCode": "切回验证码模式呗", "backupCode": "备份码", "authCode": "验证码", "title": "登录", - "twoFactorHint": "输一下 2FA 验证码呗", - "accountHint": "用你的账号密码登录啦,笨蛋", + "twoFactorHint": "输一下 2FA 验证码呗,保护你这杂鱼账号可费劲了", + "accountHint": "用账号密码登录啦,这都不会的话当什么杂鱼", "consoleTitle": "HiDNS 管理中心", - "consoleSubtitle": "管好你的域名,杂鱼大叔", - "authCodeRequired": "把验证码输进去啦,笨蛋", - "backupCodeRequired": "备份码呢?快填啦", - "rememberMe": "记住我嘛", - "trustDevice": "信任这个设备", - "trustDeviceDays": "{days}天内不用重新登录" + "consoleSubtitle": "管好你的域名哦,杂鱼大叔,别搞丢了", + "authCodeRequired": "验证码都不输,你是想卡在这一步到天荒地老吗?", + "backupCodeRequired": "备份码呢?快填啦,杂鱼!", + "rememberMe": "记住我嘛,下次省得再输入", + "trustDevice": "信任这台设备啦,敢乱点吗", + "trustDeviceDays": "{days}天内不用再烦我登录啦", + "brandLine1": "登录以继续使用", + "brandLine2Suffix": " 来管理", + "brandLine3": "你的域名呢~", + "via": "通过", + "toContinue": "以继续", + "continue": "继续", + "usernameStepLine1": "输入用户名", + "usernameStepLine2": "以继续", + "passwordStepLine1": "输入", + "passwordStepLine2": "你的密码", + "back": "返回", + "encryptionFailed": "密码加密失败了,杂鱼再试一次,别气馁哦(假笑)", + "httpWarning": "你用的是 HTTP 哦,密码会以明文传送,笨蛋你知道这很危险吗?还是硬要继续?", + "httpWarningTitle": "不安全的连接哦~", + "httpWarningConfirm": "继续啦", + "httpWarningCancel": "才不要" }, "mail": { "add": "给我加记录!", diff --git a/client/src/i18n/locales/zh-CN.json b/client/src/i18n/locales/zh-CN.json index 8ac3780d..de878f53 100644 --- a/client/src/i18n/locales/zh-CN.json +++ b/client/src/i18n/locales/zh-CN.json @@ -637,7 +637,23 @@ "usernameOrEmail": "用户名/邮箱", "usernameOrEmailPlaceholder": "请输入用户名或邮箱", "usernamePlaceholder": "请输入用户名", - "verify2FA": "验证双因素认证" + "verify2FA": "验证双因素认证", + "brandLine1": "登录以继续使用", + "brandLine2Suffix": " 管理", + "brandLine3": "您的域名", + "via": "通过", + "toContinue": "以继续", + "continue": "继续", + "usernameStepLine1": "输入用户名", + "usernameStepLine2": "以继续", + "passwordStepLine1": "输入", + "passwordStepLine2": "您的密码", + "back": "返回", + "encryptionFailed": "密码加密失败,请重试", + "httpWarning": "当前使用 HTTP 协议,密码将以明文传输,存在安全风险。是否继续?", + "httpWarningTitle": "不安全的连接", + "httpWarningConfirm": "继续", + "httpWarningCancel": "取消" }, "mail": { "add": "添加记录", diff --git a/client/src/pages/Dashboard.css b/client/src/pages/Dashboard.css index b87d4de4..4ff96073 100644 --- a/client/src/pages/Dashboard.css +++ b/client/src/pages/Dashboard.css @@ -32,15 +32,6 @@ font-weight: 700; line-height: 36px; letter-spacing: 0; - text-shadow: 0 1px 3px var(--td-title-shadow-color); -} - -:root.dark .dashboard-heading h1 { - --td-title-shadow-color: rgba(0, 0, 0, 0.8); -} - -:root.light .dashboard-heading h1 { - --td-title-shadow-color: rgba(255, 255, 255, 0.8); } .dashboard-heading p { diff --git a/client/src/pages/Landing.css b/client/src/pages/Landing.css index 55a1645c..01016d8b 100644 --- a/client/src/pages/Landing.css +++ b/client/src/pages/Landing.css @@ -1,910 +1,656 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap'); - +/* ============== Base ============== */ .landing { position: relative; width: 100%; - height: 100vh; - overflow: hidden; - background: #0a0e1a; - font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - color: #e8edf5; -} - -/* ============== Particles ============== */ -.landing-particles { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - pointer-events: none; - z-index: 1; + min-height: 100vh; + overflow-x: hidden; + background: #08090b; + color: #f5f7fb; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } -/* ============== Noise ============== */ -.landing-noise { +/* ============== Header ============== */ +.landing-header { position: fixed; top: 0; left: 0; - width: 100%; - height: 100%; - pointer-events: none; - z-index: 2; - opacity: 0.035; - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E"); - background-repeat: repeat; - background-size: 256px 256px; - animation: noiseShift 25s linear infinite; + right: 0; + z-index: 100; + padding: 0 2.5rem; + height: 72px; + display: flex; + align-items: center; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); } -@keyframes noiseShift { - 0% { transform: translate(0, 0) scale(1.1); } - 25% { transform: translate(-3%, -2%) scale(1.05); } - 50% { transform: translate(0, 0) scale(1.1); } - 75% { transform: translate(3%, 2%) scale(1.05); } - 100% { transform: translate(0, 0) scale(1.1); } +.landing-header.scrolled { + background: rgba(8, 9, 11, 0.75); + backdrop-filter: blur(20px) saturate(1.8); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); } -/* ============== Gradient Blobs ============== */ -.landing-blobs { - position: fixed; - top: 0; - left: 0; +.landing-header-inner { + max-width: 1200px; + margin: 0 auto; width: 100%; - height: 100%; - pointer-events: none; - z-index: 0; - overflow: hidden; -} - -.landing-blob { - position: absolute; - border-radius: 50%; - filter: blur(120px); - opacity: 0.4; -} - -.landing-blob-1 { - width: 600px; - height: 600px; - top: -150px; - right: -150px; - background: radial-gradient(circle, rgba(59, 130, 246, 0.3) 0%, transparent 70%); - animation: blobFloat 20s ease-in-out infinite; -} - -.landing-blob-2 { - width: 500px; - height: 500px; - bottom: -100px; - left: -100px; - background: radial-gradient(circle, rgba(139, 92, 246, 0.25) 0%, transparent 70%); - animation: blobFloat 25s ease-in-out infinite reverse; + display: flex; + justify-content: space-between; + align-items: center; } -.landing-blob-3 { - width: 400px; - height: 400px; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - background: radial-gradient(circle, rgba(16, 185, 129, 0.15) 0%, transparent 70%); - animation: blobFloat 30s ease-in-out infinite; +.landing-header-logo { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 1.25rem; + font-weight: 700; + color: #f5f7fb; + text-decoration: none; + transition: color 0.2s; } -@keyframes blobFloat { - 0% { transform: translate(0, 0) scale(1); } - 33% { transform: translate(30px, -30px) scale(1.05); } - 66% { transform: translate(-20px, 20px) scale(0.95); } - 100% { transform: translate(0, 0) scale(1); } +.landing-header-logo:hover { + color: #fff; } -/* ============== Animated Grid ============== */ -.landing-grid { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - pointer-events: none; - z-index: 0; - background-image: - linear-gradient(rgba(59, 130, 246, 0.04) 1px, transparent 1px), - linear-gradient(90deg, rgba(59, 130, 246, 0.04) 1px, transparent 1px); - background-size: 60px 60px; - animation: gridMove 30s linear infinite; +/* ============== Glass Button ============== */ +.landing-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 1.5rem; + border-radius: 9999px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + text-decoration: none; + border: none; + outline: none; + font-family: inherit; } -@keyframes gridMove { - 0% { background-position: 0 0; } - 100% { background-position: 60px 60px; } +.landing-btn-primary { + background: rgba(255, 255, 255, 0.84); + color: #121212; + backdrop-filter: blur(12.8px); } -/* ============== Section Background Overlays ============== */ -.landing-section-bg { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - pointer-events: none; - z-index: 0; - transition: opacity 1s ease; - opacity: 0; +.landing-btn-primary:hover { + background: #fff; + transform: scale(1.03); } -.landing-section-bg.visible { - opacity: 1; +.landing-btn-primary:active { + transform: scale(0.97); } -/* ============== Header ============== */ -.landing-header { - position: fixed; - top: 0; - left: 0; - right: 0; - z-index: 100; - padding: 0.75rem 2rem; - background: rgba(10, 14, 26, 0.6); - backdrop-filter: blur(16px) saturate(1.5); - border-bottom: 1px solid rgba(100, 210, 255, 0.08); - transition: all 0.5s ease; +.landing-btn-outline { + background: transparent; + color: rgba(255, 255, 255, 0.84); + border: 1px solid rgba(255, 255, 255, 0.84); } -.landing-header.scrolled { - background: rgba(10, 14, 26, 0.85); - backdrop-filter: blur(20px) saturate(1.8); - border-bottom-color: rgba(100, 210, 255, 0.15); +.landing-btn-outline:hover { + background: rgba(255, 255, 255, 0.84); + color: #121212; + transform: scale(1.03); } -.landing-header-inner { - max-width: 1200px; - margin: 0 auto; - display: flex; - justify-content: space-between; - align-items: center; +.landing-btn-outline:active { + transform: scale(0.97); } -.landing-header-logo { - display: flex; - align-items: center; - gap: 0.6rem; - font-size: 1.3rem; - font-weight: 700; - color: #64d2ff; - text-shadow: 0 0 20px rgba(100, 210, 255, 0.3); +.landing-btn-large { + padding: 0.875rem 2rem; + font-size: 1rem; } -/* ============== Wrapper ============== */ -.landing-wrapper { +/* ============== Hero Section ============== */ +.landing-hero { position: relative; - z-index: 10; - transition: transform 0.8s cubic-bezier(0.65, 0, 0.35, 1); - will-change: transform; -} - -/* ============== Section ============== */ -.landing-section { - width: 100%; - height: 100vh; + min-height: 100vh; display: flex; flex-direction: column; - align-items: center; justify-content: center; - padding: 6rem 2rem 2rem; + padding: clamp(80px, 10vh, 120px) clamp(20px, 5vw, 80px) clamp(40px, 5vh, 80px); + max-width: 1200px; + margin: 0 auto; + width: 100%; box-sizing: border-box; - overflow-y: auto; - position: relative; } -/* ============== Badge ============== */ .landing-badge { display: inline-flex; align-items: center; gap: 0.5rem; - padding: 0.5rem 1.2rem; - background: rgba(59, 130, 246, 0.1); - border: 1px solid rgba(59, 130, 246, 0.25); - border-radius: 50px; - font-size: 0.85rem; - color: #64d2ff; - margin-bottom: 1.5rem; - animation: fadeInDown 0.6s ease-out both; -} - -@keyframes fadeInDown { - from { opacity: 0; transform: translateY(-20px); } - to { opacity: 1; transform: translateY(0); } + padding: 0.5rem 1rem; + background: rgba(0, 166, 255, 0.08); + border: 1px solid rgba(0, 166, 255, 0.2); + border-radius: 9999px; + font-size: 0.8rem; + font-weight: 500; + color: #00A6FF; + margin-bottom: 2rem; + width: fit-content; } -/* ============== Hero ============== */ -.landing-hero-content { - text-align: center; - max-width: 720px; +.landing-hero-title { + font-size: clamp(48px, 8vw, 80px); + font-weight: 700; + line-height: 1.1; + margin: 0; + letter-spacing: -0.02em; + color: #f5f7fb; } -.landing-title { - font-size: 5rem; - font-weight: 800; - margin: 0 0 0.5rem; - background: linear-gradient(135deg, #64d2ff 0%, #a78bfa 50%, #f472b6 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - letter-spacing: -0.02em; - text-shadow: none; - filter: drop-shadow(0 0 40px rgba(100, 210, 255, 0.15)); - animation: fadeInUp 0.8s ease-out 0.2s both; +.landing-hero-subtitle { + font-size: clamp(18px, 3vw, 24px); + font-weight: 600; + color: #f5f7fb; + margin: 0.75rem 0 0; + line-height: 1.3; } -.landing-title-sub { - display: block; - font-size: 1.4rem; - font-weight: 500; - color: #8899b0; - -webkit-text-fill-color: #8899b0; - margin-top: 0.5rem; - letter-spacing: 0; - filter: none; - animation: fadeInUp 0.8s ease-out 0.4s both; -} - -.landing-desc { - font-size: 1.1rem; - line-height: 1.8; - margin: 0 auto 2.5rem; - color: #8899b0; +.landing-hero-desc { + font-size: clamp(15px, 1.8vw, 18px); + font-weight: 400; + line-height: 1.7; + color: rgba(245, 247, 251, 0.56); max-width: 560px; - animation: fadeInUp 0.8s ease-out 0.5s both; + margin: 1.5rem 0 2.5rem; } .landing-hero-actions { display: flex; gap: 1rem; - justify-content: center; - animation: fadeInUp 0.8s ease-out 0.6s both; -} - -@keyframes fadeInUp { - from { opacity: 0; transform: translateY(30px); } - to { opacity: 1; transform: translateY(0); } + align-items: center; } -/* ============== Section Title ============== */ -.landing-section-title { - font-size: 2.8rem; - font-weight: 700; - margin: 0 0 1rem; - text-align: center; - background: linear-gradient(135deg, #64d2ff, #a78bfa); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; +.landing-hero-fade { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 12%; + background: linear-gradient(to bottom, rgba(8,9,11,0), #08090b 85%); + pointer-events: none; } -.section-active .landing-section-title { - animation: fadeInUp 0.6s ease-out both; +/* ============== Cards Container ============== */ +.landing-cards { + max-width: 1200px; + margin: 0 auto; + padding: 0 clamp(20px, 5vw, 80px) 4rem; + display: flex; + flex-direction: column; + gap: 24px; } -/* ============== Pipeline ============== */ -.landing-pipeline-subtitle { - font-size: 1rem; - color: #6b7d98; - margin: -0.5rem 0 2rem; - text-align: center; - max-width: 600px; +/* ============== Card Base ============== */ +.landing-card { + position: relative; + overflow: hidden; + border-radius: 32px; } -.section-active .landing-pipeline-subtitle { - animation: fadeInUp 0.6s ease-out 0.15s both; +/* ============== Hero Card (DNS Management) ============== */ +.landing-card-hero { + display: grid; + grid-template-columns: 1fr 1fr; + min-height: 420px; + background: #15171c; } -.landing-pipeline { +.landing-card-hero-content { + padding: 3rem; display: flex; - align-items: stretch; - gap: 0; - max-width: 1100px; - width: 100%; - overflow-x: auto; - padding: 0.5rem 0; - scrollbar-width: none; + flex-direction: column; + justify-content: center; } -.landing-pipeline::-webkit-scrollbar { - display: none; +.landing-card-hero-label { + font-size: 0.8rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 2px; + color: rgba(0, 166, 255, 0.6); + margin: 0 0 1rem; } -.section-active .landing-pipeline-layer { - animation: pipelineIn 0.6s ease-out both; +.landing-card-hero-title { + font-size: clamp(28px, 4vw, 48px); + font-weight: 700; + line-height: 1.1; + margin: 0 0 1rem; + color: #f5f7fb; } -@keyframes pipelineIn { - from { opacity: 0; transform: translateY(20px); } - to { opacity: 1; transform: translateY(0); } +.landing-card-hero-desc { + font-size: 1rem; + font-weight: 400; + line-height: 1.6; + color: rgba(245, 247, 251, 0.56); + margin: 0; } -.landing-pipeline-layer { +.landing-card-hero-visual { display: flex; - flex-direction: column; align-items: center; - min-width: 180px; - flex-shrink: 0; - position: relative; + justify-content: center; + padding: 2rem; } -.landing-pipeline-layer-header { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 1rem; - background: rgba(59, 130, 246, 0.1); - border: 1px solid rgba(59, 130, 246, 0.2); +.landing-card-hero-panel { + width: 100%; + max-width: 400px; + background: #252933; border-radius: 20px; - margin-bottom: 1rem; - white-space: nowrap; -} - -.landing-pipeline-layer-icon { - font-size: 1rem; - color: #64d2ff; - display: flex; + overflow: hidden; } -.landing-pipeline-layer-title { - font-size: 0.85rem; - font-weight: 600; - color: #64d2ff; - letter-spacing: 0.5px; +.landing-card-hero-panel-inner { + padding: 1.5rem; } -.landing-pipeline-items { - display: flex; - flex-direction: column; - gap: 0.6rem; - width: 100%; +.landing-card-hero-dns-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; } -.landing-pipeline-item { +.landing-card-hero-dns-item { display: flex; - flex-direction: column; align-items: center; - gap: 0.25rem; - padding: 0.75rem 0.8rem; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(100, 210, 255, 0.08); - border-radius: 10px; - text-align: center; - transition: all 0.3s ease; - cursor: default; + gap: 0.5rem; + padding: 0.75rem 1rem; + background: rgba(255, 255, 255, 0.04); + border-radius: 12px; + font-size: 0.85rem; + font-weight: 500; + color: rgba(245, 247, 251, 0.7); } -.landing-pipeline-item:hover { - background: rgba(59, 130, 246, 0.06); - border-color: rgba(100, 210, 255, 0.2); - transform: translateX(3px); +/* ============== Two-Column Row ============== */ +.landing-cards-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; } -.landing-pipeline-item-icon { - font-size: 1.2rem; - color: #64d2ff; +/* ============== Blue Card (Security) ============== */ +.landing-card-blue { + background: #0084ff; + padding: 2.5rem; display: flex; + flex-direction: column; + justify-content: space-between; + min-height: 360px; } -.landing-pipeline-item-label { - font-size: 0.8rem; - font-weight: 600; - color: #e8edf5; +.landing-card-accent-title { + font-size: clamp(24px, 3vw, 36px); + font-weight: 700; + line-height: 1.15; + margin: 0 0 1rem; + color: #fff; } -.landing-pipeline-item-desc { - font-size: 0.7rem; - color: #6b7d98; +.landing-card-accent-desc { + font-size: 0.95rem; + font-weight: 400; + line-height: 1.6; + color: rgba(255, 255, 255, 0.75); + margin: 0 0 2rem; } -.landing-pipeline-connector { +.landing-card-accent-badge { display: flex; align-items: center; - justify-content: center; - height: 24px; - margin: 0.3rem 0; + gap: 0.75rem; } -.landing-pipeline-arrow { - width: 16px; - height: 16px; - border-right: 2px solid rgba(100, 210, 255, 0.3); - border-bottom: 2px solid rgba(100, 210, 255, 0.3); - transform: rotate(-45deg); - animation: arrowPulse 2s ease-in-out infinite; +.landing-card-accent-badge-label { + font-size: 0.8rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.5); + letter-spacing: 1px; + text-transform: uppercase; } -@keyframes arrowPulse { - 0% { opacity: 0.3; transform: rotate(-45deg) translate(0, 0); } - 50% { opacity: 1; transform: rotate(-45deg) translate(3px, 3px); } - 100% { opacity: 0.3; transform: rotate(-45deg) translate(0, 0); } +.landing-card-accent-badge-value { + font-size: clamp(32px, 4vw, 48px); + font-weight: 700; + color: rgba(255, 255, 255, 0.84); } -/* ============== Features ============== */ -.landing-features-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 1.5rem; - max-width: 780px; - width: 100%; +/* ============== Bordered Card (NS Monitoring) ============== */ +.landing-card-bordered { + background: #15171c; + padding: 2.5rem; + display: flex; + flex-direction: column; + justify-content: space-between; + min-height: 360px; } -.section-active .landing-feature-card { - animation: cardIn 0.6s ease-out both; +.landing-card-bordered-title { + font-size: clamp(24px, 3vw, 36px); + font-weight: 700; + line-height: 1.15; + margin: 0 0 1rem; + color: #f5f7fb; } -@keyframes cardIn { - from { opacity: 0; transform: translateY(30px) scale(0.95); } - to { opacity: 1; transform: translateY(0) scale(1); } +.landing-card-bordered-desc { + font-size: 0.95rem; + font-weight: 400; + line-height: 1.6; + color: rgba(245, 247, 251, 0.56); + margin: 0 0 2rem; } -.landing-feature-card { - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(100, 210, 255, 0.08); - border-radius: 16px; - padding: 2rem 1.5rem; - text-align: center; - transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); - cursor: default; - backdrop-filter: blur(8px); - position: relative; - overflow: hidden; +.landing-card-bordered-icons { + display: flex; + gap: 1rem; } -.landing-feature-card::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: linear-gradient(135deg, rgba(59, 130, 246, 0.05), rgba(139, 92, 246, 0.05)); - opacity: 0; - transition: opacity 0.4s ease; - border-radius: 16px; +.landing-card-bordered-icon-circle { + width: 48px; + height: 48px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.06); + display: flex; + align-items: center; + justify-content: center; + color: rgba(245, 247, 251, 0.5); } -.landing-feature-card:hover::before { - opacity: 1; +/* ============== Multi-Provider Card ============== */ +.landing-card-multi { + display: grid; + grid-template-columns: 1fr 1fr; + min-height: 360px; + background: #15171c; } -.landing-feature-card:hover { - transform: translateY(-6px); - border-color: rgba(100, 210, 255, 0.25); - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4), 0 0 40px rgba(59, 130, 246, 0.05); +.landing-card-multi-content { + padding: 3rem; + display: flex; + flex-direction: column; + justify-content: center; } -.landing-feature-icon { - font-size: 2.5rem; - margin-bottom: 1rem; - color: #64d2ff; - position: relative; - z-index: 1; +.landing-card-multi-label { + font-size: 0.8rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 2px; + color: rgba(0, 166, 255, 0.6); + margin: 0 0 1rem; } -.landing-feature-name { - font-size: 1.15rem; - font-weight: 600; - margin: 0 0 0.6rem; - color: #e8edf5; - position: relative; - z-index: 1; +.landing-card-multi-title { + font-size: clamp(24px, 3.5vw, 40px); + font-weight: 700; + line-height: 1.15; + margin: 0 0 1rem; + color: #f5f7fb; } -.landing-feature-desc { - font-size: 0.9rem; +.landing-card-multi-desc { + font-size: 1rem; + font-weight: 400; line-height: 1.6; + color: rgba(245, 247, 251, 0.56); margin: 0; - color: #8899b0; - position: relative; - z-index: 1; -} - -.landing-features-more { - margin-top: 2rem; - animation: fadeInUp 0.6s ease-out 0.5s both; -} - -/* ============== CTA ============== */ -.landing-cta-content { - text-align: center; - max-width: 600px; } -.landing-cta-icon { - font-size: 3rem; - color: #64d2ff; - margin-bottom: 1rem; - animation: float 4s ease-in-out infinite; -} - -@keyframes float { - 0% { transform: translateY(0); } - 50% { transform: translateY(-12px); } - 100% { transform: translateY(0); } +.landing-card-multi-visual { + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; } -.landing-cta-desc { - font-size: 1.15rem; - line-height: 1.8; - margin: 0 auto 2.5rem; - color: #8899b0; - max-width: 520px; +.landing-card-multi-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; + width: 100%; + max-width: 360px; } -.landing-cta-stats { +.landing-card-multi-item { display: flex; - justify-content: center; align-items: center; - gap: 2rem; - margin-bottom: 2.5rem; + gap: 0.5rem; + padding: 0.75rem 1rem; + background: rgba(255, 255, 255, 0.04); + border-radius: 12px; + font-size: 0.85rem; + font-weight: 500; + color: rgba(245, 247, 251, 0.7); +} + +.landing-card-multi-name { + white-space: nowrap; } -.landing-cta-stat { +/* ============== CTA Card ============== */ +.landing-card-cta { + background: linear-gradient(135deg, #0084ff 0%, #00A6FF 100%); + padding: 4rem 3rem; + text-align: center; display: flex; flex-direction: column; align-items: center; - gap: 0.3rem; + justify-content: center; + min-height: 280px; } -.landing-cta-stat-value { - font-size: 1.8rem; +.landing-card-cta-title { + font-size: clamp(28px, 4vw, 42px); font-weight: 700; - background: linear-gradient(135deg, #64d2ff, #a78bfa); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; + line-height: 1.15; + margin: 0 0 1rem; + color: #fff; } -.landing-cta-stat-label { - font-size: 0.85rem; - color: #6b7d98; +.landing-card-cta-desc { + font-size: 1rem; + font-weight: 400; + line-height: 1.7; + color: rgba(255, 255, 255, 0.75); + max-width: 500px; + margin: 0 0 2rem; } -.landing-cta-divider { - width: 1px; - height: 40px; - background: linear-gradient(to bottom, transparent, rgba(100, 210, 255, 0.2), transparent); +.landing-card-cta .landing-btn-primary { + background: #fff; + color: #0084ff; } -/* ============== Footer ============== */ -.landing-footer-content { - text-align: center; - color: #6b7d98; - font-size: 0.9rem; +.landing-card-cta .landing-btn-primary:hover { + background: #f0f0f0; } -.landing-footer-logo { - display: flex; - align-items: center; - justify-content: center; - gap: 0.6rem; - font-size: 1.5rem; - font-weight: 700; - color: #64d2ff; - margin-bottom: 1rem; -} - -.landing-footer-desc { - font-size: 1rem; - color: #6b7d98; - margin: 0 0 1.5rem; +/* ============== Footer ============== */ +.landing-footer { + max-width: 1200px; + margin: 0 auto; + padding: 2rem clamp(20px, 5vw, 80px) 3rem; } .landing-footer-links { display: flex; justify-content: center; align-items: center; - gap: 0.8rem; - margin-bottom: 1.5rem; + gap: 2rem; + margin-bottom: 1rem; } .landing-footer-link { background: none; border: none; - color: #64d2ff; + color: rgba(245, 247, 251, 0.42); cursor: pointer; - font-size: 0.95rem; + font-size: 0.85rem; + font-family: inherit; padding: 0; - transition: all 0.2s; text-decoration: none; + transition: color 0.2s; } .landing-footer-link:hover { - opacity: 0.7; - text-decoration: underline; -} - -.landing-footer-link-dot { - color: rgba(100, 210, 255, 0.2); + color: rgba(245, 247, 251, 0.7); } .landing-footer-copyright { + text-align: center; + font-size: 0.75rem; + color: rgba(245, 247, 251, 0.25); margin: 0; - font-size: 0.8rem; - color: #4a5a70; -} - -/* ============== Nav Dots ============== */ -.landing-nav { - position: fixed; - right: 2rem; - top: 50%; - transform: translateY(-50%); - z-index: 100; - display: flex; - flex-direction: column; - gap: 1rem; -} - -.landing-nav-dot { - width: 12px; - height: 12px; - border-radius: 50%; - border: none; - background: transparent; - cursor: pointer; - padding: 0; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.3s ease; - position: relative; -} - -.landing-nav-dot::before { - content: ''; - position: absolute; - width: 20px; - height: 20px; - border-radius: 50%; - border: 2px solid rgba(100, 210, 255, 0.3); - transition: all 0.3s ease; } -.landing-nav-dot-inner { - width: 6px; - height: 6px; - border-radius: 50%; - background: rgba(100, 210, 255, 0.4); - transition: all 0.3s ease; -} - -.landing-nav-dot:hover .landing-nav-dot-inner { - background: #64d2ff; - transform: scale(1.3); -} - -.landing-nav-dot:hover::before { - border-color: rgba(100, 210, 255, 0.6); - transform: scale(1.1); -} - -.landing-nav-dot.active .landing-nav-dot-inner { - background: #64d2ff; - box-shadow: 0 0 12px rgba(100, 210, 255, 0.6); - transform: scale(1.2); -} - -.landing-nav-dot.active::before { - border-color: #64d2ff; - box-shadow: 0 0 16px rgba(100, 210, 255, 0.25); -} - -/* ============== Scroll Hint ============== */ -.landing-scroll-hint { - position: fixed; - bottom: 2.5rem; - left: 50%; - transform: translateX(-50%); - z-index: 100; - display: flex; - flex-direction: column; - align-items: center; - gap: 0.75rem; - transition: all 0.6s ease; +/* ============== Scroll Animations ============== */ +.landing-fade-in { opacity: 0; - transform: translateX(-50%) translateY(10px); - pointer-events: none; + transform: translateY(30px); + transition: opacity 0.7s ease, transform 0.7s ease; } -.landing-scroll-hint.visible { +.landing-fade-in.visible { opacity: 1; - transform: translateX(-50%) translateY(0); + transform: translateY(0); } -.landing-scroll-hint-text { - font-size: 0.8rem; - color: rgba(100, 210, 255, 0.4); - letter-spacing: 1px; - text-transform: uppercase; +.landing-fade-in-delay-1 { transition-delay: 0.1s; } +.landing-fade-in-delay-2 { transition-delay: 0.2s; } +.landing-fade-in-delay-3 { transition-delay: 0.3s; } +.landing-fade-in-delay-4 { transition-delay: 0.4s; } + +/* ============== Scroll Reveal (About) ============== */ +.landing-scroll-section { + position: relative; + height: 150vh; } -.landing-scroll-mouse { - width: 24px; - height: 38px; - border: 2px solid rgba(100, 210, 255, 0.2); - border-radius: 12px; +.landing-scroll-sticky { + position: sticky; + top: 0; + height: 100vh; display: flex; + align-items: center; justify-content: center; - padding-top: 8px; -} - -.landing-scroll-wheel { - width: 3px; - height: 9px; - background: linear-gradient(to bottom, #64d2ff, rgba(100, 210, 255, 0.3)); - border-radius: 2px; - animation: scrollWheel 2s ease-in-out infinite; -} - -@keyframes scrollWheel { - 0% { transform: translateY(0); opacity: 1; } - 100% { transform: translateY(12px); opacity: 0; } + padding: 0 clamp(20px, 5vw, 80px); } -/* ============== TDesign Overrides ============== */ -.landing .t-button--variant-outline { - border-color: rgba(100, 210, 255, 0.3); - color: #64d2ff; - transition: all 0.3s ease; -} - -.landing .t-button--variant-outline:hover { - border-color: #64d2ff; - color: #64d2ff; - background: rgba(100, 210, 255, 0.08); - box-shadow: 0 0 20px rgba(100, 210, 255, 0.1); -} - -.landing .t-button--primary { - background: linear-gradient(135deg, #3b82f6, #8b5cf6); - border: none; - transition: all 0.3s ease; - box-shadow: 0 4px 20px rgba(59, 130, 246, 0.25); +.landing-scroll-text { + max-width: 900px; + text-align: center; } -.landing .t-button--primary:hover { - background: linear-gradient(135deg, #2563eb, #7c3aed); - box-shadow: 0 8px 30px rgba(59, 130, 246, 0.35); - transform: translateY(-1px); +.landing-scroll-text span { + display: inline; + font-size: clamp(20px, 2.8vw, 36px); + font-weight: 400; + line-height: 1.6; + color: #3a3f4a; + transition: color 0.15s ease; + will-change: color; } -.landing .t-button--size-large { - height: 48px; - padding: 0 28px; - font-size: 1rem; - border-radius: 12px; +.landing-scroll-text span.revealed { + color: rgba(245, 247, 251, 0.84); } /* ============== Responsive ============== */ -@media (max-width: 768px) { - .landing-title { - font-size: 3.2rem; - } - - .landing-title-sub { - font-size: 1.1rem; - } - - .landing-desc { - font-size: 0.95rem; - padding: 0 1rem; - } - - .landing-section-title { - font-size: 2rem; - } - - .landing-features-grid { +@media (max-width: 1024px) { + .landing-card-hero { grid-template-columns: 1fr; - max-width: 400px; - gap: 1rem; } - .landing-feature-card { - padding: 1.5rem 1.2rem; + .landing-card-hero-visual { + display: none; } - .landing-pipeline-layer { - min-width: 150px; + .landing-card-multi { + grid-template-columns: 1fr; } - .landing-pipeline-item { - padding: 0.6rem 0.6rem; + .landing-card-multi-visual { + display: none; } +} - .landing-nav { - right: 1rem; - gap: 0.8rem; +@media (max-width: 768px) { + .landing-header { + padding: 0 1rem; } - .landing-header { - padding: 0.6rem 1rem; + .landing-hero { + padding-top: 100px; + padding-left: 1.25rem; + padding-right: 1.25rem; } .landing-hero-actions { flex-direction: column; - align-items: center; - gap: 0.8rem; + align-items: flex-start; + gap: 0.75rem; } - .landing-cta-stats { - gap: 1rem; + .landing-cards { + padding-left: 1.25rem; + padding-right: 1.25rem; } - .landing-cta-stat-value { - font-size: 1.4rem; - } - - .landing-badge { - font-size: 0.75rem; - padding: 0.4rem 1rem; - } -} - -@media (max-width: 480px) { - .landing-title { - font-size: 2.6rem; + .landing-cards-row { + grid-template-columns: 1fr; } - .landing-title-sub { - font-size: 1rem; + .landing-card-blue, + .landing-card-bordered { + min-height: 280px; } - .landing-feature-card { - padding: 1.2rem 1rem; + .landing-card-cta { + padding: 3rem 2rem; } .landing-footer-links { flex-direction: column; - gap: 0.5rem; - } - - .landing-footer-link-dot { - display: none; + gap: 1rem; } } -@media (max-height: 600px) { - .landing-section { - padding-top: 4rem; - padding-bottom: 1rem; - } - - .landing-title { - font-size: 3rem; - } - - .landing-pipeline-layer { - min-width: 130px; - } - - .landing-pipeline-item { - padding: 0.4rem 0.5rem; - } - - .landing-pipeline-item-icon { - font-size: 1rem; +@media (max-width: 480px) { + .landing-hero-title { + font-size: 40px; } - .landing-pipeline-item-label { - font-size: 0.7rem; + .landing-card-hero-content { + padding: 2rem; } - .landing-pipeline-item-desc { - display: none; + .landing-card-multi-content { + padding: 2rem; } +} - .landing-features-grid { - gap: 0.8rem; +/* ============== Reduced Motion ============== */ +@media (prefers-reduced-motion: reduce) { + .landing-fade-in { + opacity: 1; + transform: none; + transition: none; } - .landing-feature-card { - padding: 1rem; + .landing-scroll-text span { + color: rgba(245, 247, 251, 0.84); + transition: none; } -} \ No newline at end of file +} diff --git a/client/src/pages/Landing.tsx b/client/src/pages/Landing.tsx index 364db3f5..4a518584 100644 --- a/client/src/pages/Landing.tsx +++ b/client/src/pages/Landing.tsx @@ -1,437 +1,328 @@ -import { useState, useRef, useEffect, useCallback } from 'react'; -import { Button } from 'tdesign-react'; -import { ArrowRightIcon, CloudIcon, InternetIcon, LockOnIcon, RocketIcon, UserIcon, ApiIcon, KeyIcon, ServerIcon, DataBaseIcon, AnalyticsIcon, NotificationIcon, SettingIcon, WifiIcon } from 'tdesign-icons-react'; +import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useI18n } from '../contexts/I18nContext'; +import { ProviderIcon } from '../components/ProviderIcon'; import './Landing.css'; -const TOTAL_SECTIONS = 4; -const SWITCH_DURATION = 800; - -function createParticles(canvas: HTMLCanvasElement) { - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; - - const particles: Array<{ x: number; y: number; size: number; speedX: number; speedY: number; opacity: number; pulse: number }> = []; - const count = Math.min(100, Math.floor((canvas.width * canvas.height) / 12000)); - - for (let i = 0; i < count; i++) { - particles.push({ - x: Math.random() * canvas.width, - y: Math.random() * canvas.height, - size: Math.random() * 2.5 + 0.5, - speedX: (Math.random() - 0.5) * 0.3, - speedY: (Math.random() - 0.5) * 0.3, - opacity: Math.random() * 0.5 + 0.15, - pulse: Math.random() * Math.PI * 2, - }); - } - - let animId = 0; - const animate = () => { - ctx.clearRect(0, 0, canvas.width, canvas.height); - - for (const p of particles) { - p.pulse += 0.02; - const pulseSize = p.size * (1 + Math.sin(p.pulse) * 0.3); - - ctx.beginPath(); - ctx.arc(p.x, p.y, pulseSize, 0, Math.PI * 2); - ctx.fillStyle = `rgba(100, 210, 255, ${p.opacity})`; - ctx.fill(); - - const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, pulseSize * 3); - glow.addColorStop(0, `rgba(100, 210, 255, ${p.opacity * 0.3})`); - glow.addColorStop(1, 'rgba(100, 210, 255, 0)'); - ctx.beginPath(); - ctx.arc(p.x, p.y, pulseSize * 3, 0, Math.PI * 2); - ctx.fillStyle = glow; - ctx.fill(); - - p.x += p.speedX; - p.y += p.speedY; - - if (p.x < 0) p.x = canvas.width; - if (p.x > canvas.width) p.x = 0; - if (p.y < 0) p.y = canvas.height; - if (p.y > canvas.height) p.y = 0; - } - - for (let i = 0; i < particles.length; i++) { - for (let j = i + 1; j < particles.length; j++) { - const dx = particles[i].x - particles[j].x; - const dy = particles[i].y - particles[j].y; - const dist = Math.sqrt(dx * dx + dy * dy); - if (dist < 150) { - ctx.beginPath(); - ctx.moveTo(particles[i].x, particles[i].y); - ctx.lineTo(particles[j].x, particles[j].y); - ctx.strokeStyle = `rgba(100, 210, 255, ${0.08 * (1 - dist / 150)})`; - ctx.lineWidth = 0.5; - ctx.stroke(); - } - } - } +const RocketSvg = () => ( + + + + + + +); + +const dnsProviders = [ + { type: 'cloudflare', name: 'Cloudflare' }, + { type: 'aliyun', name: 'Aliyun' }, + { type: 'dnspod', name: 'DNSPod' }, + { type: 'huawei', name: 'Huawei' }, + { type: 'gcore', name: 'Gcore' }, + { type: 'baidu', name: 'Baidu' }, +]; - animId = requestAnimationFrame(animate); - }; +const multiProviders = [ + { type: 'aliyunesa', name: 'Aliyun ESA' }, + { type: 'jdcloud', name: 'JD Cloud' }, + { type: 'west', name: 'West' }, + { type: 'namesilo', name: 'NameSilo' }, + { type: 'tencenteo', name: 'Tencent EO' }, + { type: 'dnshe', name: 'DNSHE' }, +]; - const resize = () => { - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; - }; +function useScrollReveal() { + const ref = useRef(null); + const [visible, setVisible] = useState(false); - window.addEventListener('resize', resize); - animate(); + useEffect(() => { + const el = ref.current; + if (!el) return; - return () => { - cancelAnimationFrame(animId); - window.removeEventListener('resize', resize); - }; -} + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setVisible(true); + } + }, + { threshold: 0.15 } + ); -const sections = [ - { - key: 'hero', - gradient: 'radial-gradient(ellipse at 20% 50%, rgba(59, 130, 246, 0.15) 0%, transparent 50%), radial-gradient(ellipse at 80% 50%, rgba(139, 92, 246, 0.12) 0%, transparent 50%)', - }, - { - key: 'pipeline', - gradient: 'radial-gradient(ellipse at 80% 20%, rgba(59, 130, 246, 0.12) 0%, transparent 50%), radial-gradient(ellipse at 20% 80%, rgba(16, 185, 129, 0.08) 0%, transparent 50%)', - }, - { - key: 'features', - gradient: 'radial-gradient(ellipse at 50% 0%, rgba(139, 92, 246, 0.15) 0%, transparent 50%), radial-gradient(ellipse at 50% 100%, rgba(59, 130, 246, 0.1) 0%, transparent 50%)', - }, - { - key: 'footer', - gradient: 'radial-gradient(ellipse at 30% 50%, rgba(59, 130, 246, 0.08) 0%, transparent 50%)', - }, -]; + observer.observe(el); + return () => observer.disconnect(); + }, []); -const pipelineLayers = [ - { - title: '接入层', - icon: , - items: [ - { icon: , label: 'Web UI', desc: 'React SPA' }, - { icon: , label: 'REST API', desc: 'Express' }, - { icon: , label: 'WebSocket', desc: '实时推送' }, - ], - }, - { - title: '安全层', - icon: , - items: [ - { icon: , label: 'JWT 认证', desc: 'Session 管理' }, - { icon: , label: 'API Token', desc: '自动化接入' }, - { icon: , label: '2FA 验证', desc: 'TOTP/WebAuthn' }, - ], - }, - { - title: '核心引擎', - icon: , - items: [ - { icon: , label: '域名管理', desc: '多平台聚合' }, - { icon: , label: 'DNS 记录', desc: '全记录类型' }, - { icon: , label: 'NS 监测', desc: '实时监控' }, - ], - }, - { - title: '适配层', - icon: , - items: [ - { icon: , label: 'Gcore DNS', desc: 'API v2' }, - { icon: , label: 'Cloudflare', desc: '边缘网络' }, - { icon: , label: '更多接入', desc: 'AliDNS / DNSPod' }, - ], - }, - { - title: '运维保障', - icon: , - items: [ - { icon: , label: '审计日志', desc: '操作追溯' }, - { icon: , label: '告警通知', desc: '即时推送' }, - { icon: , label: '自动容灾', desc: 'Failover' }, - ], - }, -]; + return { ref, visible }; +} -export function Landing() { - const navigate = useNavigate(); - const { t } = useI18n(); - const [activeSection, setActiveSection] = useState(0); - const [headerVisible, setHeaderVisible] = useState(true); +function FadeInSection({ children, delay = 0, className = '' }: { children: React.ReactNode; delay?: number; className?: string }) { + const { ref, visible } = useScrollReveal(); + return ( +
+ {children} +
+ ); +} + +function ScrollRevealText({ text }: { text: string }) { const containerRef = useRef(null); - const wrapperRef = useRef(null); - const isScrolling = useRef(false); - const touchStartY = useRef(0); - const canvasRef = useRef(null); - const sectionRefs = useRef<(HTMLElement | null)[]>([]); - - const features = [ - { - icon: , - title: t('landing.features.dnsManagement.title'), - description: t('landing.features.dnsManagement.description'), - }, - { - icon: , - title: t('landing.features.nsMonitoring.title'), - description: t('landing.features.nsMonitoring.description'), - }, - { - icon: , - title: t('landing.features.security.title'), - description: t('landing.features.security.description'), - }, - { - icon: , - title: t('landing.features.multiProvider.title'), - description: t('landing.features.multiProvider.description'), - }, - ]; - - const scrollToSection = useCallback((index: number) => { - if (isScrolling.current) return; - const target = Math.max(0, Math.min(TOTAL_SECTIONS - 1, index)); - if (target === activeSection) return; - isScrolling.current = true; - setActiveSection(target); - setHeaderVisible(target === 0); - if (wrapperRef.current) { - wrapperRef.current.style.transform = `translateY(-${target * 100}vh)`; - } - setTimeout(() => { isScrolling.current = false; }, SWITCH_DURATION); - }, [activeSection]); - - const handleWheel = useCallback((e: WheelEvent) => { - if (isScrolling.current) return; - e.preventDefault(); - const delta = e.deltaY > 0 ? 1 : -1; - scrollToSection(activeSection + delta); - }, [activeSection, scrollToSection]); - - const handleKeyDown = useCallback((e: KeyboardEvent) => { - if (isScrolling.current) return; - if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { - e.preventDefault(); - scrollToSection(activeSection + 1); - } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { - e.preventDefault(); - scrollToSection(activeSection - 1); - } else if (e.key === 'Home') { - e.preventDefault(); - scrollToSection(0); - } else if (e.key === 'End') { - e.preventDefault(); - scrollToSection(TOTAL_SECTIONS - 1); - } - }, [activeSection, scrollToSection]); + const charsRef = useRef([]); useEffect(() => { - if (!canvasRef.current) return; - const cleanup = createParticles(canvasRef.current); - return cleanup; - }, []); + const container = containerRef.current; + if (!container) return; - useEffect(() => { - const el = containerRef.current; - if (!el) return; + const handleScroll = () => { + const rect = container.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const sectionStart = rect.top; + const sectionHeight = rect.height; - const onTouchStart = (e: TouchEvent) => { touchStartY.current = e.touches[0].clientY; }; - const onTouchEnd = (e: TouchEvent) => { - const deltaY = touchStartY.current - e.changedTouches[0].clientY; - if (Math.abs(deltaY) > 40) { - scrollToSection(activeSection + (deltaY > 0 ? 1 : -1)); - } - }; + if (sectionStart > viewportHeight || sectionStart + sectionHeight < 0) return; - el.addEventListener('wheel', handleWheel, { passive: false }); - el.addEventListener('touchstart', onTouchStart, { passive: true }); - el.addEventListener('touchend', onTouchEnd, { passive: true }); + const progress = Math.max(0, Math.min(1, (viewportHeight - sectionStart) / (viewportHeight + sectionHeight))); + const charCount = charsRef.current.length; + const revealedCount = Math.floor(progress * charCount * 1.5); - return () => { - el.removeEventListener('wheel', handleWheel); - el.removeEventListener('touchstart', onTouchStart); - el.removeEventListener('touchend', onTouchEnd); + charsRef.current.forEach((span, i) => { + if (span) { + span.classList.toggle('revealed', i < revealedCount); + } + }); }; - }, [activeSection, handleWheel, scrollToSection]); - useEffect(() => { - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [handleKeyDown]); + window.addEventListener('scroll', handleScroll, { passive: true }); + handleScroll(); + return () => window.removeEventListener('scroll', handleScroll); + }, [text]); + + const chars = useMemo(() => text.split(''), [text]); return ( -
- -
- -
-
-
-
+
+
+
+ {chars.map((char, i) => ( + { charsRef.current[i] = el as HTMLSpanElement; }} + > + {char} + + ))} +
+
+ ); +} -
+export function Landing() { + const navigate = useNavigate(); + const { t } = useI18n(); + const [headerScrolled, setHeaderScrolled] = useState(false); + const cardsRef = useRef(null); - {sections.map((s, i) => ( -
- ))} + const scrollRevealText = useMemo(() => + 'HiDNS 是一个现代化的 DNS 聚合管理平台,支持 22 家主流 DNS 服务商的统一管理。从域名解析到安全监控,从自动化运维到团队协作,为您提供全方位的 DNS 管理解决方案。', + [] + ); -
+ useEffect(() => { + const handleScroll = () => { + setHeaderScrolled(window.scrollY > 50); + }; + window.addEventListener('scroll', handleScroll, { passive: true }); + return () => window.removeEventListener('scroll', handleScroll); + }, []); + + const scrollToCards = useCallback(() => { + cardsRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, []); + + return ( +
+ {/* Header */} +
- + HiDNS
- +
-
- {['首页', '流水线', '功能', '关于'].map((label, i) => ( - - ))} -
- -
- {/* Section 0: Hero */} -
{ sectionRefs.current[0] = el; }} - > -
-
- - DNS Manager Platform + {/* Hero */} +
+ +
+ + DNS Manager Platform +
+
+ + +

+ HiDNS +

+
+ + +

{t('landing.hero.subtitle')}

+
+ + +

{t('landing.hero.description')}

+
+ + +
+ + +
+
+ +
+
+ + {/* Scroll Reveal Text */} + + + {/* Cards Section */} +
+ + {/* Card 1: DNS 聚合管理 — Hero Card */} + +
+
+

DNS 聚合管理

+

+ {t('landing.features.dnsManagement.title')} +

+

+ {t('landing.features.dnsManagement.description')} +

-

- HiDNS - 一站式 DNS 聚合管理平台 -

-

{t('landing.hero.description')}

-
- - +
+
+
+
+ {dnsProviders.map((p) => ( +
+ + {p.name} +
+ ))} +
+
+
-
- - {/* Section 1: Pipeline - Core Component Workflow */} -
{ sectionRefs.current[1] = el; }} - > -

核心工作流水线

-

从用户接入到 DNS 解析分发,全链路覆盖

-
- {pipelineLayers.map((layer, li) => ( -
-
- {layer.icon} - {layer.title} + + + {/* Row: 安全 (blue) | NS 监控 (bordered) */} +
+ +
+

+ {t('landing.features.security.title')} +

+

+ {t('landing.features.security.description')} +

+
+ Security + 2FA + JWT +
+
+
+ + +
+

+ {t('landing.features.nsMonitoring.title')} +

+

+ {t('landing.features.nsMonitoring.description')} +

+
+
+ + +
-
- {layer.items.map((item, ii) => ( -
- {item.icon} - {item.label} - {item.desc} -
- ))} +
+ + +
- {li < pipelineLayers.length - 1 && ( -
-
-
- )} -
- ))} -
-
- - {/* Section 2: Features */} -
{ sectionRefs.current[2] = el; }} - > -

{t('landing.features.title')}

-
- {features.map((f, i) => ( -
-
{f.icon}
-

{f.title}

-

{f.description}

- ))} -
-
- -
-
- - {/* Section 3: Footer */} -
{ sectionRefs.current[3] = el; }} - > -
-
- - HiDNS
-

- 开源 · 安全 · 高效 —— 现代化 DNS 管理平台 -

-
- - · - - GitHub - - · - + +
+ + {/* Card 3: 多平台支持 — Full Width */} + +
+
+

Multi-Provider

+

+ {t('landing.features.multiProvider.title')} +

+

+ {t('landing.features.multiProvider.description')} +

+
+
+
+ {multiProviders.map((p) => ( +
+ + {p.name} +
+ ))} +
-

{t('landing.footer.copyright')}

-
-
+ + + {/* CTA */} + +
+

{t('landing.cta.title')}

+

{t('landing.cta.description')}

+ +
+
+ + -
- 滚动探索 -
-
+ {/* Footer */} +
+
+ + + GitHub + +
-
+

{t('landing.footer.copyright')}

+
); -} \ No newline at end of file +} diff --git a/client/src/pages/Login.css b/client/src/pages/Login.css index fbe6b2a9..a2c0ee41 100644 --- a/client/src/pages/Login.css +++ b/client/src/pages/Login.css @@ -1,9 +1,10 @@ +/* ─── Page ───────────────────────────────────────────────────────────────── */ .login-page { - min-height: var(--app-scaled-vh); + min-height: var(--app-scaled-vh, 100vh); display: grid; place-items: center; padding: clamp(20px, 4vw, 56px); - background: #f4f6f8; + background: #f8fbff; color: #1f2329; } @@ -13,408 +14,508 @@ padding: 24px; } -.login-shell { +.login-footer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + text-align: center; + padding: 14px 20px; + font-size: 12px; + color: #999; + z-index: 10; + pointer-events: none; + user-select: none; +} + +html.dark .login-footer { color: #666; } +html.dark .login-page { background: #111418; } + +/* ─── Card ───────────────────────────────────────────────────────────────── */ +.lc-card { position: relative; - display: grid; - grid-template-columns: minmax(360px, 1fr) minmax(360px, 440px); - width: min(100%, 1040px); - min-height: 620px; + width: 867px; + height: 467px; + background: var(--lc-bg, #fbfbfb); + border-radius: 5px; overflow: hidden; - border: 1px solid #e3e6eb; - border-radius: 8px; - background: #fff; - box-shadow: 0 20px 48px rgba(31, 35, 41, 0.08); + box-shadow: 0 4px 40px rgba(0, 0, 0, 0.10); } -.login-identity { - position: relative; - display: grid; - grid-template-rows: auto 1fr; - min-width: 0; - padding: 44px; - border-right: 1px solid #e7e7e7; - background: - linear-gradient(180deg, rgba(0, 82, 217, 0.035), transparent 42%), - #f8fafc; +html.dark .lc-card { + --lc-bg: #1c2027; + --lc-surface: #252b35; + --lc-text: #f3f6fb; + --lc-text-muted: #a6b0c0; + --lc-border: #3f4652; + --lc-input-bg: #171b22; + box-shadow: 0 4px 40px rgba(0, 0, 0, 0.32); } -.login-brand { - display: flex; - align-items: center; - gap: 12px; - min-width: 0; +/* ─── Language switcher ──────────────────────────────────────────────────── */ +.lc-lang { + position: absolute; + top: 6px; + right: 8px; + z-index: 20; } -.login-brand__mark { - width: 40px; - height: 40px; - display: inline-flex; +.lc-lang-btn { + width: 32px; + height: 32px; + display: flex; align-items: center; justify-content: center; - flex: 0 0 auto; - border: 1px solid #dcdfe6; - border-radius: 8px; - background: #fff; + background: none; + border: none; + border-radius: 6px; + color: var(--lc-text-muted, #8a94a6); + cursor: pointer; + font-size: 18px; + transition: background 0.15s ease, color 0.15s ease; } -.login-brand__mark img { - width: 24px; - height: 24px; - display: block; +.lc-lang-btn:hover { + background: var(--lc-surface-hover, rgba(0, 0, 0, 0.06)); + color: var(--lc-text, #1f2329); } -.login-brand > div { - display: grid; - gap: 2px; - min-width: 0; +html.dark .lc-lang-btn:hover { + background: rgba(255, 255, 255, 0.08); + color: var(--lc-text, #f3f6fb); } -.login-brand strong { - color: #1f2329; - font-size: 16px; - line-height: 24px; +.lc-lang__menu .t-dropdown__item--active { + color: #0482f8; } -.login-brand span, -.login-identity__copy p, -.login-panel__heading p { - color: #5f6673; - font-size: 14px; - line-height: 22px; +/* ─── Left branding panel ────────────────────────────────────────────────── */ +.lc-branding { + position: absolute; + left: 4px; + top: 7px; + width: 460px; + height: 453px; + background: #fff; + border-radius: 5px; } -.login-identity__copy { - align-self: center; - max-width: 460px; - padding-bottom: 24px; +html.dark .lc-branding { background: var(--lc-surface); } + +.lc-branding__logo { + position: absolute; + left: 44px; + top: 115px; + display: flex; + align-items: center; + gap: 3px; } -.login-identity__copy h1, -.login-panel__heading h2 { +.lc-branding__hi { + font-family: SYS, system-ui; + font-weight: 700; + font-size: 24px; + color: #8533ff; + line-height: normal; +} + +.lc-branding__dns { + position: absolute; + left: 43px; + top: 137px; margin: 0; - color: #1f2329; + font-family: SYS, system-ui; font-weight: 600; - letter-spacing: 0; + font-size: 24px; + color: var(--lc-text, #000); + line-height: normal; } -.login-identity__copy h1 { - font-size: clamp(32px, 4vw, 44px); - line-height: 1.15; +.lc-branding__headline { + position: absolute; + left: 43px; + top: 178px; + width: 284px; + font-family: SYS, system-ui; + font-weight: 600; + font-size: 36px; + color: var(--lc-text, #000); + line-height: 1.35; } -.login-identity__copy p, -.login-panel__heading p { - margin: 10px 0 0; -} +.lc-branding__line { margin: 0; } -.login-panel { - display: flex; - flex-direction: column; - justify-content: center; - min-width: 0; - padding: 54px 44px 42px; - background: #fff; -} +.lc-branding__hicolor { color: #0482f8; } -.login-panel__heading { - margin-bottom: 28px; +/* ─── Right panel ────────────────────────────────────────────────────────── */ +.lc-card__right { + position: absolute; + left: 460px; + top: 0; + right: 0; + bottom: 0; } -.login-panel__heading h2 { - font-size: 26px; - line-height: 34px; +.lc-card__right-viewport { + position: absolute; + inset: 0; + overflow: hidden; } -.login-form__stack, -.login-reset-dialog { - width: 100%; +.lc-card__panel { + position: absolute; + inset: 0; } -.login-form .t-form__item { - margin-bottom: 0; +/* ─── Error ──────────────────────────────────────────────────────────────── */ +.lc-error { + position: absolute; + top: 12px; + left: 31px; + right: 31px; + z-index: 10; + padding: 10px 14px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 6px; + color: #991b1b; + font-family: SYS, system-ui; + font-size: 13px; + line-height: 1.4; } -.login-form .t-form__label { - color: #3f4652; - font-weight: 500; +html.dark .lc-error { + background: rgba(239, 68, 68, 0.15); + border-color: rgba(239, 68, 68, 0.3); + color: #fca5a5; } -.login-form :where(.t-input, .t-input-adornment) { - background: #fff; +/* ─── Step (shared) ──────────────────────────────────────────────────────── */ +.lc-step { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + padding-top: 132px; + box-sizing: border-box; } -.login-form :where(.t-input__inner) { - background: transparent; +.lc-step__heading { + width: 341px; + font-family: SYS, system-ui; + font-weight: 500; + font-size: 36px; + color: var(--lc-text, #000); + line-height: 1.2; + margin-bottom: 24px; } -.login-form .t-button--theme-primary { - font-weight: 600; -} +.lc-step__heading p { margin: 0; } -.login-two-factor { +/* ─── Back button ────────────────────────────────────────────────────────── */ +.lc-back { + width: 341px; + height: 27px; + background: none; + border: none; + padding: 0; + cursor: pointer; display: flex; - flex-direction: column; - gap: 16px; + align-items: center; + gap: 4px; + margin-bottom: 21px; + font-family: inherit; + color: var(--lc-text, #000); } -.login-code-input { - font-size: 20px; - font-weight: 600; - letter-spacing: 0; +/* ─── Input ──────────────────────────────────────────────────────────────── */ +.lc-input-wrap { + width: 341px; + height: 46px; + background: var(--lc-surface, #fff); + border-radius: 6px; + border: 1px solid var(--lc-border, #eee); + box-sizing: border-box; + margin-bottom: 29px; } -.login-switch-code { +.lc-input { width: 100%; - margin-top: 8px; -} - -.login-panel__footer, -.login-external-providers { - display: flex; - align-items: stretch; - justify-content: center; - min-width: 0; + height: 100%; + background: transparent; + border: none; + outline: none; + padding: 0 14px; + box-sizing: border-box; + font-family: SYS, system-ui; + font-weight: 500; + font-size: 14px; + color: var(--lc-text, #000); } -.login-panel__footer { - gap: 0; - margin-top: 20px; - padding-top: 18px; - border-top: 1px solid #e7e7e7; +.lc-input::placeholder { + color: var(--lc-text-muted, rgba(147, 147, 147, 0.38)); } -.login-external-providers { - margin-top: 8px; +.lc-input--center { + text-align: center; + font-size: 20px; + font-weight: 600; + letter-spacing: 0; } -.login-footer-action { - min-width: max-content; - max-width: 100%; - height: 34px; - display: inline-flex; +/* ─── Primary button ─────────────────────────────────────────────────────── */ +.lc-btn-primary { + width: 341px; + height: 39px; + background: #004ffa; + border-radius: 6px; + border: none; + display: flex; align-items: center; justify-content: center; gap: 6px; - padding: 0 14px; - border: 0; - border-radius: 0; - background: transparent; - color: #0052d9; - font-size: 14px; - line-height: 1; cursor: pointer; - white-space: nowrap; -} - -.login-footer-action + .login-footer-action { - border-left: 1px solid #dcdfe6; -} - -.login-footer-action:hover { - color: #266fe8; + opacity: 1; + transition: opacity 0.15s; + margin-bottom: 12px; + font-family: inherit; } -.login-footer-action:disabled { - color: #a8abb2; +.lc-btn-primary:disabled { + opacity: 0.5; cursor: not-allowed; } -.login-footer-action svg { - width: 16px; - height: 16px; - flex: 0 0 auto; -} +html.dark .lc-btn-primary { background: #266fe8; } -.login-footer-action .t-button__text { - display: inline-flex; +/* ─── Outline button ─────────────────────────────────────────────────────── */ +.lc-btn-outline { + width: 100%; + height: 39px; + background: transparent; + border-radius: 6px; + border: 1px solid var(--lc-border, #eee); + display: flex; align-items: center; + justify-content: center; gap: 6px; - min-width: 0; - overflow: visible; - white-space: nowrap; + cursor: pointer; + font-family: SYS, system-ui; + font-weight: 500; + font-size: 14px; + color: var(--lc-text, #000); + transition: background 0.15s; } -.login-reset-code-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 8px; - width: 100%; - min-width: 0; -} +.lc-btn-outline:hover { background: var(--lc-surface, #f5f5f5); } -.login-reset-code-row > .t-input { - min-width: 0; +/* ─── SSO row ────────────────────────────────────────────────────────────── */ +.lc-sso { + width: 341px; + height: 36px; + background: var(--lc-surface, #fff); + border-radius: 6px; + border: 1px solid rgba(192, 192, 192, 0.16); + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + cursor: pointer; + transition: background 0.15s; + font-family: inherit; + margin-bottom: 6px; } -.login-reset-code-button { - width: auto; - min-width: 104px; - flex: 0 0 auto; - white-space: nowrap; +.lc-sso:disabled { + opacity: 0.5; + cursor: not-allowed; } -.login-reset-code-button .t-button__text { - overflow: visible; - white-space: nowrap; +.lc-sso:hover:not(:disabled) { + background: var(--lc-surface, #f9f9f9); } -html.dark .login-page { - background: #111418; - color: #f3f6fb; +html.dark .lc-sso { + background: var(--lc-surface); + border-color: var(--lc-border); } -.login-footer { - position: fixed; - bottom: 0; - left: 0; - right: 0; +/* ─── Divider ────────────────────────────────────────────────────────────── */ +.lc-divider { + width: 341px; + display: flex; + align-items: center; + gap: 12px; + margin: 4px 0; + font-family: SYS, system-ui; + font-size: 13px; + color: var(--lc-text-muted, #999); +} + +.lc-divider::before, +.lc-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--lc-border, #eee); +} + +/* ─── Switch code link ───────────────────────────────────────────────────── */ +.lc-switch-code { + width: 341px; + background: none; + border: none; + padding: 4px 0; + cursor: pointer; + font-family: SYS, system-ui; + font-weight: 500; + font-size: 13px; + color: #0089fa; text-align: center; - padding: 14px 20px; - font-size: 12px; - color: #999; - z-index: 10; - pointer-events: none; - user-select: none; + margin-bottom: 8px; } -html.dark .login-footer { - color: #666; -} +.lc-switch-code:hover { text-decoration: underline; } -html.dark .login-shell { - border-color: #343b47; - background: #1c2027; - box-shadow: 0 20px 48px rgba(0, 0, 0, 0.28); -} - -html.dark .login-identity { - border-right-color: #343b47; - background: - linear-gradient(180deg, rgba(69, 143, 255, 0.08), transparent 44%), - #171b22; +/* ─── Spinner ────────────────────────────────────────────────────────────── */ +.lc-spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: #fff; + border-radius: 50%; + animation: lc-spin 0.6s linear infinite; } -html.dark .login-panel__footer { - border-top-color: #343b47; +.lc-spinner--sm { + width: 14px; + height: 14px; } -html.dark .login-brand__mark { - border-color: #3f4652; - background: #202631; +@keyframes lc-spin { + to { transform: rotate(360deg); } } -html.dark .login-brand strong, -html.dark .login-identity__copy h1, -html.dark .login-panel__heading h2 { - color: #f3f6fb; -} +/* ─── Icon helpers ───────────────────────────────────────────────────────── */ +.lc-icon-24 { width: 24px; height: 24px; position: relative; overflow: hidden; flex-shrink: 0; } +.lc-icon-20 { width: 20px; height: 20px; position: relative; overflow: hidden; flex-shrink: 0; display: flex; align-items: center; justify-content: center; } +.lc-icon-22 { width: 22px; height: 22px; position: relative; overflow: hidden; flex-shrink: 0; } +.lc-icon-30 { width: 30px; height: 27px; position: relative; overflow: hidden; flex-shrink: 0; } +.lc-icon-inset { position: absolute; inset: 16.67%; } -html.dark .login-brand span, -html.dark .login-identity__copy p, -html.dark .login-panel__heading p { - color: #a6b0c0; +/* ─── Reset dialog overlay ───────────────────────────────────────────────── */ +.lc-reset-overlay { + position: absolute; + inset: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(4px); } -html.dark .login-panel { - background: #1c2027; +.lc-reset-dialog { + width: 380px; + background: var(--lc-bg, #fff); + border-radius: 12px; + padding: 28px; + display: flex; + flex-direction: column; + gap: 14px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); } -html.dark .login-form .t-form__label { - color: #d7dce5; -} +html.dark .lc-reset-dialog { background: #252b35; } -html.dark .login-form .t-input, -html.dark .login-form .t-input-adornment { - border-color: #3f4652; - background: #171b22 !important; - background-color: #171b22 !important; - background-image: none !important; - color: #f3f6fb; +.lc-reset-dialog h3 { + margin: 0 0 4px; + font-family: SYS, system-ui; + font-weight: 600; + font-size: 18px; + color: var(--lc-text, #000); } -html.dark .login-form :where(.t-input__inner) { - background: transparent !important; - color: #f3f6fb; - -webkit-text-fill-color: #f3f6fb; +.lc-reset-code-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; } -html.dark .login-form :where(.t-input__inner::placeholder) { - color: #7d8794; - -webkit-text-fill-color: #7d8794; +.lc-reset-code-row .lc-input-wrap { + width: auto; + margin-bottom: 0; } -html.dark .login-form input:-webkit-autofill, -html.dark .login-form input:-webkit-autofill:hover, -html.dark .login-form input:-webkit-autofill:focus { - caret-color: #f3f6fb; - box-shadow: 0 0 0 1000px #171b22 inset; - -webkit-text-fill-color: #f3f6fb; +.lc-reset-code-row .lc-btn-outline { + width: auto; + min-width: 100px; + height: 46px; + padding: 0 14px; } -html.dark .login-footer-action { - color: #72a7ff; +.lc-reset-actions { + display: flex; + gap: 10px; + margin-top: 4px; } -html.dark .login-footer-action + .login-footer-action { - border-left-color: #3f4652; +.lc-reset-actions .lc-btn-outline { + flex: 1; + height: 39px; } -html.dark .login-footer-action:hover { - color: #9fc4ff; +.lc-reset-actions .lc-btn-primary { + flex: 1; } +/* ─── Responsive ─────────────────────────────────────────────────────────── */ @media (max-width: 900px) { - .login-page { - padding: 24px 18px; - } + .login-page { padding: 24px 18px; } - .login-shell { - grid-template-columns: minmax(0, 1fr); + .lc-card { width: min(100%, 520px); - min-height: 0; + height: auto; + min-height: 467px; } - .login-identity { - gap: 28px; - padding: 28px; - border-right: 0; - border-bottom: 1px solid #e7e7e7; - } - - .login-identity__copy { - padding-bottom: 0; - } - - .login-identity__copy h1 { - font-size: 30px; - } + .lc-branding { display: none; } - .login-panel { - padding: 32px 28px; - } - - html.dark .login-identity { - border-bottom-color: #343b47; + .lc-card__right { + position: relative; + left: auto; + top: auto; + right: auto; + bottom: auto; + min-height: 467px; } } @media (max-width: 520px) { - .login-page { - padding: 14px; - } + .login-page { padding: 14px; } - .login-identity, - .login-panel { - padding: 24px 18px; + .lc-card { + width: 100%; + border-radius: 8px; } - .login-panel__footer, - .login-external-providers { - flex-wrap: wrap; + .lc-step { padding-top: 48px; } + + .lc-step__heading, + .lc-input-wrap, + .lc-btn-primary, + .lc-sso, + .lc-divider, + .lc-switch-code { + width: min(100%, 341px); } - .login-footer-action { - padding: 0 10px; + .lc-reset-dialog { + width: calc(100% - 32px); + padding: 20px; } } diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx index 3e223559..a69655ce 100644 --- a/client/src/pages/Login.tsx +++ b/client/src/pages/Login.tsx @@ -1,64 +1,20 @@ -import { useState, useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { Alert, Button, Dialog, Divider, Form, Input, Loading, Space } from 'tdesign-react'; -import { - FingerprintIcon, - KeyIcon, - LockOnIcon, - MailIcon, - SecuredIcon, - UserIcon, -} from 'tdesign-icons-react'; +import { Loading } from 'tdesign-react'; import { useAuth } from '../contexts/AuthContext'; import { useI18n } from '../contexts/I18nContext'; -import { authApi, initApi } from '../api'; -import type { WebAuthnResponse } from '../api'; -import { useToast } from '../hooks/useToast'; -import { startAuthentication } from '@simplewebauthn/browser'; -import { encryptPassword } from '../utils/rsaEncrypt'; +import { initApi } from '../api'; +import LoginCard from './LoginCard'; import './Login.css'; -type ApiErrorPayload = { - msg?: string; - message?: string; -}; - -function getApiErrorMessage(error: unknown, fallback: string) { - if (typeof error === 'object' && error !== null && 'response' in error) { - const response = (error as { response?: { data?: ApiErrorPayload } }).response; - const message = response?.data?.msg ?? response?.data?.message; - if (message) return message; - } - - return error instanceof Error ? error.message : fallback; -} - export function Login() { const year = new Date().getFullYear(); - const { user, login } = useAuth(); + const { user } = useAuth(); const { t } = useI18n(); - const toast = useToast(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const returnTo = searchParams.get('return_to') || '/dash'; - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); const [checking, setChecking] = useState(true); - const [showReset, setShowReset] = useState(false); - const [resetEmail, setResetEmail] = useState(''); - const [resetCode, setResetCode] = useState(''); - const [resetNewPassword, setResetNewPassword] = useState(''); - const [resetLoading, setResetLoading] = useState(false); - const [oauthEnabled, setOauthEnabled] = useState(false); - const [oauthProviders, setOauthProviders] = useState>([]); - const [oauthLoading, setOauthLoading] = useState(false); - const [require2FA, setRequire2FA] = useState(false); - const [supported2FATypes, setSupported2FATypes] = useState([]); - const [totpCode, setTotpCode] = useState(''); - const [useBackupCode, setUseBackupCode] = useState(false); - const [backupCode, setBackupCode] = useState(''); useEffect(() => { if (user) { @@ -71,184 +27,9 @@ export function Login() { navigate('/setup'); } }) - .catch(() => { - // If status cannot be checked, keep the login page available. - }) - .finally(() => { - setChecking(false); - }); - }, [navigate]); - - useEffect(() => { - authApi.oauthStatus() - .then((res) => { - if (res.data.code === 0 && res.data.data.enabled) { - setOauthEnabled(true); - setOauthProviders(res.data.data.providers || []); - } else { - setOauthEnabled(false); - setOauthProviders([]); - } - }) - .catch(() => { - setOauthEnabled(false); - setOauthProviders([]); - }); - }, []); - - const submitLogin = async () => { - if (!username || !password) { - setError(t('login.required')); - return; - } - if (require2FA && !useBackupCode && !totpCode) { - setError(t('login.authCodeRequired')); - return; - } - if (require2FA && useBackupCode && !backupCode) { - setError(t('login.backupCodeRequired')); - return; - } - - setError(''); - setLoading(true); - try { - // Encrypt password before sending - let passwordToSend: string; - try { - passwordToSend = await encryptPassword(password); - } catch (encryptError) { - console.warn('Password encryption failed, falling back to plain text:', encryptError); - passwordToSend = password; // Fallback to plain text if encryption fails - } - - await login( - username, - passwordToSend, - require2FA && !useBackupCode ? totpCode : undefined, - require2FA && useBackupCode ? backupCode : undefined, - undefined, - true, // encrypted flag - ); - navigate(returnTo); - } catch (err: any) { - if (err.message === '2FA_REQUIRED') { - setRequire2FA(true); - setSupported2FATypes(err.types || ['totp']); - setError(''); - } else { - setError(err.message || t('login.failed')); - } - } finally { - setLoading(false); - } - }; - - const handlePasskeyLogin = async () => { - try { - setLoading(true); - setError(''); - const optsRes = await authApi.webauthnLoginOptions(username); - if (optsRes.data.code !== 0) throw new Error(optsRes.data.msg); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const attResp = await startAuthentication({ optionsJSON: optsRes.data.data.options as any }); - - // Encrypt password for WebAuthn login too - let passwordToSend: string; - try { - passwordToSend = await encryptPassword(password); - } catch (encryptError) { - console.warn('Password encryption failed, falling back to plain text:', encryptError); - passwordToSend = password; - } - - await login(username, passwordToSend, undefined, undefined, attResp as unknown as WebAuthnResponse, true); - navigate(returnTo); - } catch (e: unknown) { - setError(e instanceof Error ? e.message : t('login.failed')); - } finally { - setLoading(false); - } - }; - - const sendResetCode = async () => { - if (!resetEmail.trim()) { - toast.error(t('login.resetEmailRequired')); - return; - } - setResetLoading(true); - try { - const res = await authApi.requestPasswordReset(resetEmail.trim()); - if (res.data.code !== 0) { - toast.error(res.data.msg || t('login.resetRequestFailed')); - return; - } - toast.success(t('login.resetCodeSent')); - } catch (e) { - toast.error(e instanceof Error ? e.message : t('login.resetRequestFailed')); - } finally { - setResetLoading(false); - } - }; - - const confirmReset = async () => { - if (!resetEmail.trim() || !resetCode.trim() || !resetNewPassword.trim()) { - toast.error(t('login.resetFieldsRequired')); - return; - } - setResetLoading(true); - try { - // Encrypt password before sending - let encryptedPassword: string; - try { - encryptedPassword = await encryptPassword(resetNewPassword); - } catch (encryptError) { - console.warn('Password encryption failed, falling back to plain text:', encryptError); - encryptedPassword = resetNewPassword; - } - - const res = await authApi.confirmPasswordReset(resetEmail.trim(), resetCode.trim(), encryptedPassword, true); - if (res.data.code !== 0) { - toast.error(res.data.msg || t('login.resetConfirmFailed')); - return; - } - toast.success(t('login.resetPasswordSuccess')); - setShowReset(false); - setResetCode(''); - setResetNewPassword(''); - } catch (e) { - toast.error(e instanceof Error ? e.message : t('login.resetConfirmFailed')); - } finally { - setResetLoading(false); - } - }; - - const startOauthLogin = async (provider?: 'custom' | 'logto') => { - setError(''); - const isProviderReady = oauthEnabled && ( - provider ? oauthProviders.some((item) => item.key === provider) : oauthProviders.length > 0 - ); - - if (!isProviderReady) { - setError(provider === 'logto' ? t('login.logtoUnavailable') : t('login.oauthUnavailable')); - return; - } - - setOauthLoading(true); - try { - const res = await authApi.oauthStart(provider); - if (res.data.code !== 0) { - setError(res.data.msg || t('login.oauthFailed')); - return; - } - window.location.href = res.data.data.authUrl; - } catch (e) { - setError(getApiErrorMessage(e, t('login.oauthFailed'))); - } finally { - setOauthLoading(false); - } - }; + .catch(() => {}) + .finally(() => setChecking(false)); + }, [navigate, user]); if (checking) { return ( @@ -263,247 +44,10 @@ export function Login() { return ( <> -
-
-
-
- - - -
- HiDNS - {t('login.subtitle')} -
-
- -
-

{t('login.consoleTitle')}

-

{t('login.consoleSubtitle')}

-
-
- -
-
-

{require2FA ? t('login.verify2FA') : t('login.title')}

-

{require2FA ? t('login.twoFactorHint') : t('login.accountHint')}

-
- -
{ - void submitLogin(); - }} - > - - {error && } - - {!require2FA ? ( - <> - - setUsername(value)} - placeholder={t('login.usernameOrEmailPlaceholder')} - autocomplete="username" - prefixIcon={} - /> - - - - setPassword(value)} - placeholder={t('login.passwordPlaceholder')} - autocomplete="current-password" - prefixIcon={} - /> - - - ) : ( -
- {supported2FATypes.includes('webauthn') && ( - - )} - - {supported2FATypes.includes('webauthn') && supported2FATypes.includes('totp') && ( - OR - )} - - {supported2FATypes.includes('totp') && ( - <> - {!useBackupCode ? ( - - setTotpCode(value.replace(/\D/g, ''))} - placeholder={t('login.enterAuthCode')} - autocomplete="one-time-code" - maxlength={6} - prefixIcon={} - inputClass="login-code-input" - /> - - - ) : ( - - setBackupCode(value)} - placeholder={t('login.enterBackupCode')} - prefixIcon={} - inputClass="login-code-input" - /> - - - )} - - )} -
- )} - - -
-
- -
- - - {/* 动态显示 Logto 登录(仅当后端配置了 Logto) */} - {oauthEnabled && oauthProviders.some((provider) => provider.key === 'logto') && ( - - )} -
- - {/* 动态显示其他 OAuth 提供商 */} - {oauthEnabled && oauthProviders.some((provider) => provider.key !== 'logto') && ( -
- {oauthProviders.filter((provider) => provider.key !== 'logto').map((provider) => ( - - ))} -
- )} -
-
- - setShowReset(false)} - onCancel={() => setShowReset(false)} - onConfirm={() => { - void confirmReset(); - }} - > - - setResetEmail(value)} - placeholder={t('login.resetEmailPlaceholder')} - autocomplete="email" - prefixIcon={} - /> -
- setResetCode(value)} - placeholder={t('login.resetCodePlaceholder')} - /> - -
- setResetNewPassword(value)} - placeholder={t('login.resetNewPasswordPlaceholder')} - autocomplete="new-password" - prefixIcon={} - /> -
-
-
+
+ +
©{year} HiPM-Tech · All Rights Reserved.
- ); + + ); } diff --git a/client/src/pages/LoginCard.tsx b/client/src/pages/LoginCard.tsx new file mode 100644 index 00000000..07cbfbf3 --- /dev/null +++ b/client/src/pages/LoginCard.tsx @@ -0,0 +1,684 @@ +import { useState, useRef, useEffect, useMemo, useCallback, type ReactNode } from 'react'; +import svgPaths from '../utils/loginSvgPaths'; +import { useAuth } from '../contexts/AuthContext'; +import { useI18n } from '../contexts/I18nContext'; +import { authApi } from '../api'; +import type { WebAuthnResponse } from '../api'; +import { useToast } from '../hooks/useToast'; +import { startAuthentication } from '@simplewebauthn/browser'; +import { encryptPassword } from '../utils/rsaEncrypt'; +import { Dropdown } from 'tdesign-react'; +import type { DropdownOption } from 'tdesign-react'; +import { CheckIcon, TranslateIcon } from 'tdesign-icons-react'; +import { localeOptions } from '../i18n'; +import { Modal } from '../components/Modal'; +import './Login.css'; + +const SYS = "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; + +type ApiErrorPayload = { msg?: string; message?: string }; +function getApiErrorMessage(error: unknown, fallback: string) { + if (typeof error === 'object' && error !== null && 'response' in error) { + const response = (error as { response?: { data?: ApiErrorPayload } }).response; + const message = response?.data?.msg ?? response?.data?.message; + if (message) return message; + } + return fallback; +} + +function getUserFriendlyError(error: unknown, fallback: string) { + if (typeof error === 'object' && error !== null && 'response' in error) { + const response = (error as { response?: { data?: ApiErrorPayload } }).response; + const message = response?.data?.msg ?? response?.data?.message; + if (message && message.length < 200) return message; + } + return fallback; +} + +/** + * Try RSA-encrypt the password. + * On HTTPS: encryption failure is fatal (password must not be sent in plaintext). + * On HTTP: encryption failure triggers a confirmation dialog; if the user + * confirms, the plaintext password is returned so login can proceed + * over plain HTTP (the server must still accept it). + */ +async function encryptWithFallback( + password: string, + confirmFn: () => Promise, + encryptionFailed: string, +): Promise<{ encrypted: string; plaintext: boolean }> { + try { + const encrypted = await encryptPassword(password); + return { encrypted, plaintext: false }; + } catch { + if (window.location.protocol === 'https:') { + throw new Error(encryptionFailed); + } + const confirmed = await confirmFn(); + if (!confirmed) throw new Error(encryptionFailed); + return { encrypted: password, plaintext: true }; + } +} + +/* ─── SVG icons ──────────────────────────────────────────────────────────── */ +function GlobePurple() { + return ( +
+
+ + + +
+
+ ); +} + +function ArrowForward() { + return ( +
+
+ + + +
+
+ ); +} + +function ArrowBack() { + return ( +
+
+ + + +
+
+ ); +} + +/* ─── Left branding panel ───────────────────────────────────────────────── */ +function BrandingPanel() { + const { t } = useI18n(); + return ( +
+
+ Hi + +
+

DNS

+
+

{t('login.brandLine1', { defaultValue: '登录以继续使用' })}

+

+ HiDNS + {t('login.brandLine2Suffix', { defaultValue: ' 管理' })} +

+

{t('login.brandLine3', { defaultValue: '您的域名' })}

+
+
+ ); +} + +/* ─── SSO row ───────────────────────────────────────────────────────────── */ +function SsoRow({ icon, label, brand, onClick, disabled }: { + icon: ReactNode; label: string; brand: string; onClick?: () => void; disabled?: boolean; +}) { + const { t } = useI18n(); + return ( + + ); +} + +/* ─── Step: Username ─────────────────────────────────────────────────────── */ +function UsernamePanel({ username, setUsername, onContinue, loading, oauthEnabled, oauthProviders, onOauthLogin, oauthLoading, inputRef }: { + username: string; setUsername: (v: string) => void; onContinue: () => void; loading: boolean; + oauthEnabled: boolean; oauthProviders: Array<{ key: 'custom' | 'logto'; providerName: string }>; + onOauthLogin: (provider?: 'custom' | 'logto') => void; oauthLoading: boolean; + inputRef: React.RefObject; +}) { + const { t } = useI18n(); + return ( +
+
+

{t('login.usernameStepLine1', { defaultValue: '输入用户名' })}

+

{t('login.usernameStepLine2', { defaultValue: '以继续' })}

+
+
+ } + value={username} + onChange={(e) => setUsername(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && username.trim() && onContinue()} + placeholder={t('login.usernameOrEmailPlaceholder')} + className="lc-input" + /> +
+ + {oauthEnabled && ( +
+ } + label={t('login.oauthSignIn', { defaultValue: '通过 OAuth 登录' })} + brand="" + onClick={() => onOauthLogin('custom')} + disabled={oauthLoading || loading} + /> + {oauthProviders.some((p) => p.key === 'logto') && ( + } + label={t('login.logtoSignIn', { defaultValue: '用 Logto 登录' })} + brand="" + onClick={() => onOauthLogin('logto')} + disabled={oauthLoading || loading} + /> + )} +
+ )} +
+ ); +} + +/* ─── Step: Password ─────────────────────────────────────────────────────── */ +function PasswordPanel({ password, setPassword, onBack, onSubmit, loading, username, onForgotPassword, onPasskeyLogin, supported2FATypes, inputRef }: { + password: string; setPassword: (v: string) => void; onBack: () => void; onSubmit: () => void; loading: boolean; + username: string; onForgotPassword: () => void; onPasskeyLogin: () => void; supported2FATypes: string[]; + inputRef: React.RefObject; +}) { + const { t } = useI18n(); + return ( +
+ +
+

{t('login.passwordStepLine1', { defaultValue: '输入' })}

+

{t('login.passwordStepLine2', { defaultValue: '您的密码' })}

+
+
+ } + value={password} + onChange={(e) => setPassword(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && password.trim() && onSubmit()} + placeholder={t('login.passwordPlaceholder')} + className="lc-input" + autoComplete="current-password" + /> +
+ + + {supported2FATypes.includes('webauthn') && ( + + )} +
+ ); +} + +/* ─── Step: 2FA ──────────────────────────────────────────────────────────── */ +function TwoFactorPanel({ onBack, totpCode, setTotpCode, onSubmit, loading, useBackupCode, setUseBackupCode, backupCode, setBackupCode, onPasskeyLogin, supported2FATypes, username, inputRef }: { + onBack: () => void; totpCode: string; setTotpCode: (v: string) => void; onSubmit: () => void; loading: boolean; + useBackupCode: boolean; setUseBackupCode: (v: boolean) => void; backupCode: string; setBackupCode: (v: string) => void; + onPasskeyLogin: () => void; supported2FATypes: string[]; username: string; + inputRef: React.RefObject; +}) { + const { t } = useI18n(); + return ( +
+ +
+

{t('login.verify2FA')}

+

{t('login.twoFactorHint')}

+
+ {supported2FATypes.includes('webauthn') && ( + + )} + {supported2FATypes.includes('webauthn') && supported2FATypes.includes('totp') && ( +
OR
+ )} + {!useBackupCode ? ( +
+ } + value={totpCode} + onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))} + onKeyDown={(e) => e.key === 'Enter' && totpCode.trim() && onSubmit()} + placeholder={t('login.enterAuthCode')} + className="lc-input lc-input--center" + maxLength={6} + autoComplete="one-time-code" + /> +
+ ) : ( +
+ } + value={backupCode} + onChange={(e) => setBackupCode(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && backupCode.trim() && onSubmit()} + placeholder={t('login.enterBackupCode')} + className="lc-input lc-input--center" + autoComplete="one-time-code" + /> +
+ )} + + +
+ ); +} + +/* ─── Main component ─────────────────────────────────────────────────────── */ +type Phase = 'idle' | 'exit-fwd' | 'exit-bwd'; +type ViewStep = 'username' | 'password' | '2fa'; + +export default function LoginCard() { + const { login } = useAuth(); + const { locale, setLocale, t } = useI18n(); + const toast = useToast(); + const [viewStep, setViewStep] = useState('username'); + const [phase, setPhase] = useState('idle'); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [require2FA, setRequire2FA] = useState(false); + const [supported2FATypes, setSupported2FATypes] = useState([]); + const [totpCode, setTotpCode] = useState(''); + const [useBackupCode, setUseBackupCode] = useState(false); + const [backupCode, setBackupCode] = useState(''); + const [showReset, setShowReset] = useState(false); + const [resetEmail, setResetEmail] = useState(''); + const [resetCode, setResetCode] = useState(''); + const [resetNewPassword, setResetNewPassword] = useState(''); + const [resetLoading, setResetLoading] = useState(false); + const [oauthEnabled, setOauthEnabled] = useState(false); + const [oauthProviders, setOauthProviders] = useState>([]); + const [oauthLoading, setOauthLoading] = useState(false); + const timer = useRef | null>(null); + const usernameInputRef = useRef(null); + const passwordInputRef = useRef(null); + const totpInputRef = useRef(null); + const httpWarningResolveRef = useRef<((value: boolean) => void) | null>(null); + const [httpWarningOpen, setHttpWarningOpen] = useState(false); + + const EXIT_DUR = 480; + const EASE_OUT = 'cubic-bezier(0.55,0,0.8,0.45)'; + const EASE_IN = 'cubic-bezier(0.22,1,0.36,1)'; + + useEffect(() => { + authApi.oauthStatus() + .then((res) => { + if (res.data.code === 0 && res.data.data.enabled) { + setOauthEnabled(true); + setOauthProviders(res.data.data.providers || []); + } + }) + .catch(() => {}); + }, []); + + const showHttpWarningModal = useCallback(() => { + return new Promise((resolve) => { + httpWarningResolveRef.current = resolve; + setHttpWarningOpen(true); + }); + }, []); + + const resolveHttpWarning = (confirmed: boolean) => { + httpWarningResolveRef.current?.(confirmed); + httpWarningResolveRef.current = null; + setHttpWarningOpen(false); + }; + + const languageOptions = useMemo(() => localeOptions.map((option) => ({ + content: option.label, + value: option.code, + active: option.code === locale, + prefixIcon: option.code === locale ? : undefined, + })), [locale]); + + const goForward = () => { + if (phase !== 'idle' || !username.trim()) return; + setPhase('exit-fwd'); + timer.current = setTimeout(() => { + setViewStep('password'); + setPhase('idle'); + }, EXIT_DUR); + }; + + const goBack = () => { + if (phase !== 'idle') return; + setPhase('exit-bwd'); + timer.current = setTimeout(() => { + setViewStep('username'); + setPhase('idle'); + }, EXIT_DUR); + }; + + const goTo2FA = () => { + if (phase !== 'idle') return; + setPhase('exit-fwd'); + timer.current = setTimeout(() => { + setViewStep('2fa'); + setPhase('idle'); + }, EXIT_DUR); + }; + + const goBackFrom2FA = () => { + if (phase !== 'idle') return; + setPhase('exit-bwd'); + timer.current = setTimeout(() => { + setRequire2FA(false); + setViewStep('password'); + setPhase('idle'); + }, EXIT_DUR); + }; + + useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []); + + useEffect(() => { + if (phase !== 'idle') return; + const id = setTimeout(() => { + if (viewStep === 'username') usernameInputRef.current?.focus(); + else if (viewStep === 'password') passwordInputRef.current?.focus(); + else if (viewStep === '2fa') totpInputRef.current?.focus(); + }, 60); + return () => clearTimeout(id); + }, [viewStep, phase]); + + const usernameTransform = (): React.CSSProperties => { + if (viewStep === 'username' && phase === 'idle') { + return { transform: 'translateX(0)', opacity: 1, transition: `transform 0.44s ${EASE_IN}, opacity 0.32s ease-out` }; + } + if (phase === 'exit-fwd') { + return { transform: 'translateX(-115%)', opacity: 0, transition: `transform ${EXIT_DUR}ms ${EASE_OUT}, opacity ${EXIT_DUR * 0.8}ms ${EASE_OUT}` }; + } + return { transform: 'translateX(-115%)', opacity: 0, transition: 'none' }; + }; + + const passwordTransform = (): React.CSSProperties => { + if (viewStep === 'password' && phase === 'idle') { + return { transform: 'translateX(0)', opacity: 1, transition: `transform 0.44s ${EASE_IN}, opacity 0.32s ease-out` }; + } + if (phase === 'exit-bwd') { + return { transform: 'translateX(115%)', opacity: 0, transition: `transform ${EXIT_DUR}ms ${EASE_OUT}, opacity ${EXIT_DUR * 0.8}ms ${EASE_OUT}` }; + } + if (phase === 'exit-fwd') { + return { transform: 'translateX(-115%)', opacity: 0, transition: 'none' }; + } + return { transform: 'translateX(115%)', opacity: 0, transition: 'none' }; + }; + + const twoFactorTransform = (): React.CSSProperties => { + if (viewStep === '2fa' && phase === 'idle') { + return { transform: 'translateX(0)', opacity: 1, transition: `transform 0.44s ${EASE_IN}, opacity 0.32s ease-out` }; + } + if (phase === 'exit-bwd') { + return { transform: 'translateX(115%)', opacity: 0, transition: `transform ${EXIT_DUR}ms ${EASE_OUT}, opacity ${EXIT_DUR * 0.8}ms ${EASE_OUT}` }; + } + if (phase === 'exit-fwd') { + return { transform: 'translateX(-115%)', opacity: 0, transition: 'none' }; + } + return { transform: 'translateX(115%)', opacity: 0, transition: 'none' }; + }; + + const submitLogin = async () => { + if (!username || !password) { setError(t('login.required')); return; } + setError(''); + setLoading(true); + try { + let passwordToSend: string; + try { passwordToSend = (await encryptWithFallback(password, showHttpWarningModal, t('login.encryptionFailed'))).encrypted; } catch { setLoading(false); return; } + await login(username, passwordToSend, undefined, undefined, undefined, true); + toast.success(t('login.signIn', { defaultValue: '登录成功' })); + } catch (err: any) { + if (err.message === '2FA_REQUIRED') { + setRequire2FA(true); + setSupported2FATypes(err.types || ['totp']); + goTo2FA(); + setError(''); + } else { + setError(getUserFriendlyError(err, t('login.failed'))); + } + } finally { + setLoading(false); + } + }; + + const submit2FA = async () => { + if (require2FA && !useBackupCode && !totpCode) { setError(t('login.authCodeRequired')); return; } + if (require2FA && useBackupCode && !backupCode) { setError(t('login.backupCodeRequired')); return; } + setError(''); + setLoading(true); + try { + let passwordToSend: string; + try { passwordToSend = (await encryptWithFallback(password, showHttpWarningModal, t('login.encryptionFailed'))).encrypted; } catch { setLoading(false); return; } + await login( + username, passwordToSend, + !useBackupCode ? totpCode : undefined, + useBackupCode ? backupCode : undefined, + undefined, true, + ); + toast.success(t('login.signIn', { defaultValue: '登录成功' })); + } catch (err: any) { + setError(getUserFriendlyError(err, t('login.failed'))); + } finally { + setLoading(false); + } + }; + + const handlePasskeyLogin = async () => { + try { + setLoading(true); + setError(''); + const optsRes = await authApi.webauthnLoginOptions(username); + if (optsRes.data.code !== 0) throw new Error(optsRes.data.msg); + const attResp = await startAuthentication({ optionsJSON: optsRes.data.data.options as any }); + let passwordToSend: string; + try { passwordToSend = (await encryptWithFallback(password, showHttpWarningModal, t('login.encryptionFailed'))).encrypted; } catch { setLoading(false); return; } + await login(username, passwordToSend, undefined, undefined, attResp as unknown as WebAuthnResponse, true); + toast.success(t('login.signIn', { defaultValue: '登录成功' })); + } catch (e: unknown) { + setError(getUserFriendlyError(e, t('login.failed'))); + } finally { + setLoading(false); + } + }; + + const startOauthLogin = async (provider?: 'custom' | 'logto') => { + setError(''); + const isProviderReady = oauthEnabled && ( + provider ? oauthProviders.some((item) => item.key === provider) : oauthProviders.length > 0 + ); + if (!isProviderReady) { + setError(provider === 'logto' ? t('login.logtoUnavailable') : t('login.oauthUnavailable')); + return; + } + setOauthLoading(true); + try { + const res = await authApi.oauthStart(provider); + if (res.data.code !== 0) { setError(res.data.msg || t('login.oauthFailed')); return; } + window.location.href = res.data.data.authUrl; + } catch (e) { + setError(getApiErrorMessage(e, t('login.oauthFailed'))); + } finally { + setOauthLoading(false); + } + }; + + const sendResetCode = async () => { + if (!resetEmail.trim()) { toast.error(t('login.resetEmailRequired')); return; } + setResetLoading(true); + try { + const res = await authApi.requestPasswordReset(resetEmail.trim()); + if (res.data.code !== 0) { toast.error(res.data.msg || t('login.resetRequestFailed')); return; } + toast.success(t('login.resetCodeSent')); + } catch (e) { + toast.error(getUserFriendlyError(e, t('login.resetRequestFailed'))); + } finally { + setResetLoading(false); + } + }; + + const confirmReset = async () => { + if (!resetEmail.trim() || !resetCode.trim() || !resetNewPassword.trim()) { + toast.error(t('login.resetFieldsRequired')); return; + } + setResetLoading(true); + try { + let encrypted: string; + try { encrypted = (await encryptWithFallback(resetNewPassword, showHttpWarningModal, t('login.encryptionFailed'))).encrypted; } catch { setResetLoading(false); return; } + const res = await authApi.confirmPasswordReset(resetEmail.trim(), resetCode.trim(), encrypted, true); + if (res.data.code !== 0) { toast.error(res.data.msg || t('login.resetConfirmFailed')); return; } + toast.success(t('login.resetPasswordSuccess')); + setShowReset(false); setResetCode(''); setResetNewPassword(''); + } catch (e) { + toast.error(getUserFriendlyError(e, t('login.resetConfirmFailed'))); + } finally { + setResetLoading(false); + } + }; + + return ( +
+
+ setLocale(String(option.value))} + > + + +
+ +
+ {error &&
{error}
} +
+
+ +
+
+ setShowReset(true)} onPasskeyLogin={handlePasskeyLogin} + supported2FATypes={supported2FATypes} inputRef={passwordInputRef} + /> +
+
+ +
+
+
+ + {showReset && ( +
+
+

{t('login.resetPassword')}

+ setResetEmail(e.target.value)} placeholder={t('login.resetEmailPlaceholder')} className="lc-input" /> +
+ setResetCode(e.target.value)} placeholder={t('login.resetCodePlaceholder')} className="lc-input" /> + +
+ setResetNewPassword(e.target.value)} placeholder={t('login.resetNewPasswordPlaceholder')} className="lc-input" autoComplete="new-password" /> +
+ + +
+
+
+ )} + {httpWarningOpen && ( + resolveHttpWarning(false)} size="sm"> +

{t('login.httpWarning')}

+
+ + +
+
+ )} +
+ ); +} diff --git a/client/src/styles/globals.css b/client/src/styles/globals.css index ff03621d..9ddfef7d 100644 --- a/client/src/styles/globals.css +++ b/client/src/styles/globals.css @@ -10,7 +10,6 @@ --app-scaled-vw: calc(100vw / var(--app-ui-scale)); /* Light mode (default) */ - --td-theme-button-shadow-color: rgba(255, 255, 255, 0.6); --color-bg-primary: #ffffff; --color-bg-secondary: #f9fafb; --color-bg-tertiary: #f3f4f6; @@ -38,7 +37,6 @@ /* Dark mode is resolved by ThemeContext so manual light/dark can override the OS. */ html.dark { - --td-theme-button-shadow-color: rgba(0, 0, 0, 0.6); --color-bg-primary: #111827; --color-bg-secondary: #1f2937; --color-bg-tertiary: #374151; @@ -215,10 +213,6 @@ html.dark input:-webkit-autofill:focus { flex: 0 0 auto; } -.t-button { - text-shadow: 0 1px 3px var(--td-theme-button-shadow-color, transparent); -} - .page-heading--compact { align-items: center; } @@ -358,7 +352,6 @@ html.dark input:-webkit-autofill:focus { } .t-table .t-button { - text-shadow: var(--td-theme-button-shadow, none); max-width: 100%; } @@ -496,7 +489,6 @@ html.dark input:-webkit-autofill:focus { --app-popup-shift-y: -6px; --app-popup-exit-shift-x: 0; --app-popup-exit-shift-y: -3px; - backface-visibility: hidden; transform-origin: top center; } @@ -505,9 +497,7 @@ html.dark input:-webkit-autofill:focus { --app-popup-frame-shift-y: -6px; --app-popup-frame-exit-shift-x: 0; --app-popup-frame-exit-shift-y: -3px; - backface-visibility: hidden; transform-origin: top center; - will-change: opacity, translate, scale; } .t-popup[data-popper-placement^='top'] > .t-popup__content { @@ -570,7 +560,7 @@ html.dark input:-webkit-autofill:focus { .t-popup--animation-expand-enter-active { -webkit-clip-path: none !important; clip-path: none !important; - animation: appPopupFrameIn var(--app-popup-enter-duration) var(--app-popup-motion-ease-out) both !important; + animation: appPopupFrameIn var(--app-popup-enter-duration) var(--app-popup-motion-ease-out) backwards !important; transition: none !important; } @@ -578,7 +568,7 @@ html.dark input:-webkit-autofill:focus { .t-popup--animation-expand-leave-active { -webkit-clip-path: none !important; clip-path: none !important; - animation: appPopupFrameOut var(--app-popup-exit-duration) var(--app-popup-motion-ease-in) both !important; + animation: appPopupFrameOut var(--app-popup-exit-duration) var(--app-popup-motion-ease-in) forwards !important; transition: none !important; } @@ -595,7 +585,7 @@ html.dark input:-webkit-autofill:focus { .t-popup--animation-enter-active :where(.t-select-option, .t-dropdown__item), .t-popup--animation-expand-enter-active :where(.t-select-option, .t-dropdown__item) { - animation: appPopupItemIn 180ms var(--app-popup-motion-ease-out) both; + animation: appPopupItemIn 180ms var(--app-popup-motion-ease-out) backwards; } .t-popup--animation-enter-active :where(.t-select-option, .t-dropdown__item):nth-child(2), @@ -652,14 +642,12 @@ html.dark input:-webkit-autofill:focus { opacity: 0; visibility: visible; translate: var(--app-popup-frame-shift-x) var(--app-popup-frame-shift-y); - scale: 1; } 100% { opacity: 1; visibility: visible; translate: 0 0; - scale: 1; } } @@ -668,26 +656,22 @@ html.dark input:-webkit-autofill:focus { opacity: 1; visibility: visible; translate: 0 0; - scale: 1; } 100% { opacity: 0; visibility: hidden; translate: var(--app-popup-frame-exit-shift-x) var(--app-popup-frame-exit-shift-y); - scale: 1; } } @keyframes appPopupItemIn { 0% { opacity: 0; - transform: translate3d(0, 0, 0); } 100% { opacity: 1; - transform: translate3d(0, 0, 0); } } diff --git a/client/src/utils/loginSvgPaths.ts b/client/src/utils/loginSvgPaths.ts new file mode 100644 index 00000000..04c5d388 --- /dev/null +++ b/client/src/utils/loginSvgPaths.ts @@ -0,0 +1,6 @@ +export default { +p157ad100: "M4.78125 10.125L11.7813 16.425L10 18L0 9L10 0L11.7813 1.575L4.78125 7.875H20V10.125H4.78125Z", +p1640b480: "M11.1604 8.25H0V6.41667H11.1604L6.02708 1.28333L7.33333 0L14.6667 7.33333L7.33333 14.6667L6.02708 13.3833L11.1604 8.25Z", +p3f788380: "M8.33333 16.6667C7.19444 16.6667 6.11806 16.4479 5.10417 16.0104C4.09028 15.5729 3.20486 14.9757 2.44792 14.2187C1.69097 13.4618 1.09375 12.5764 0.65625 11.5625C0.21875 10.5486 0 9.47222 0 8.33333C0 7.18056 0.21875 6.10069 0.65625 5.09375C1.09375 4.08681 1.69097 3.20486 2.44792 2.44792C3.20486 1.69097 4.09028 1.09375 5.10417 0.65625C6.11806 0.21875 7.19444 0 8.33333 0C9.48611 0 10.566 0.21875 11.5729 0.65625C12.5799 1.09375 13.4618 1.69097 14.2187 2.44792C14.9757 3.20486 15.5729 4.08681 16.0104 5.09375C16.4479 6.10069 16.6667 7.18056 16.6667 8.33333C16.6667 9.47222 16.4479 10.5486 16.0104 11.5625C15.5729 12.5764 14.9757 13.4618 14.2187 14.2187C13.4618 14.9757 12.5799 15.5729 11.5729 16.0104C10.566 16.4479 9.48611 16.6667 8.33333 16.6667ZM8.33333 14.9583C8.69444 14.4583 9.00694 13.9375 9.27083 13.3958C9.53472 12.8542 9.75 12.2778 9.91667 11.6667H6.75C6.91667 12.2778 7.13194 12.8542 7.39583 13.3958C7.65972 13.9375 7.97222 14.4583 8.33333 14.9583ZM6.16667 14.625C5.91667 14.1667 5.69792 13.691 5.51042 13.1979C5.32292 12.7049 5.16667 12.1944 5.04167 11.6667H2.58333C2.98611 12.3611 3.48958 12.9653 4.09375 13.4792C4.69792 13.9931 5.38889 14.375 6.16667 14.625ZM10.5 14.625C11.2778 14.375 11.9688 13.9931 12.5729 13.4792C13.1771 12.9653 13.6806 12.3611 14.0833 11.6667H11.625C11.5 12.1944 11.3437 12.7049 11.1562 13.1979C10.9687 13.691 10.75 14.1667 10.5 14.625ZM1.875 10H4.70833C4.66667 9.72222 4.63542 9.44792 4.61458 9.17708C4.59375 8.90625 4.58333 8.625 4.58333 8.33333C4.58333 8.04167 4.59375 7.76042 4.61458 7.48958C4.63542 7.21875 4.66667 6.94444 4.70833 6.66667H1.875C1.80556 6.94444 1.75347 7.21875 1.71875 7.48958C1.68403 7.76042 1.66667 8.04167 1.66667 8.33333C1.66667 8.625 1.68403 8.90625 1.71875 9.17708C1.75347 9.44792 1.80556 9.72222 1.875 10ZM6.375 10H10.2917C10.3333 9.72222 10.3646 9.44792 10.3854 9.17708C10.4063 8.90625 10.4167 8.625 10.4167 8.33333C10.4167 8.04167 10.4063 7.76042 10.3854 7.48958C10.3646 7.21875 10.3333 6.94444 10.2917 6.66667H6.375C6.33333 6.94444 6.30208 7.21875 6.28125 7.48958C6.26042 7.76042 6.25 8.04167 6.25 8.33333C6.25 8.625 6.26042 8.90625 6.28125 9.17708C6.30208 9.44792 6.33333 9.72222 6.375 10ZM11.9583 10H14.7917C14.8611 9.72222 14.9132 9.44792 14.9479 9.17708C14.9826 8.90625 15 8.625 15 8.33333C15 8.04167 14.9826 7.76042 14.9479 7.48958C14.9132 7.21875 14.8611 6.94444 14.7917 6.66667H11.9583C12 6.94444 12.0313 7.21875 12.0521 7.48958C12.0729 7.76042 12.0833 8.04167 12.0833 8.33333C12.0833 8.625 12.0729 8.90625 12.0521 9.17708C12.0313 9.44792 12 9.72222 11.9583 10ZM11.625 5H14.0833C13.6806 4.30556 13.1771 3.70139 12.5729 3.1875C11.9688 2.67361 11.2778 2.29167 10.5 2.04167C10.75 2.5 10.9687 2.97569 11.1562 3.46875C11.3437 3.96181 11.5 4.47222 11.625 5ZM6.75 5H9.91667C9.75 4.38889 9.53472 3.8125 9.27083 3.27083C9.00694 2.72917 8.69444 2.20833 8.33333 1.70833C7.97222 2.20833 7.65972 2.72917 7.39583 3.27083C7.13194 3.8125 6.91667 4.38889 6.75 5ZM2.58333 5H5.04167C5.16667 4.47222 5.32292 3.96181 5.51042 3.46875C5.69792 2.97569 5.91667 2.5 6.16667 2.04167C5.38889 2.29167 4.69792 2.67361 4.09375 3.1875C3.48958 3.70139 2.98611 4.30556 2.58333 5Z", +pdb50800: "M10 20C8.63333 20 7.34167 19.7375 6.125 19.2125C4.90833 18.6875 3.84583 17.9708 2.9375 17.0625C2.02917 16.1542 1.3125 15.0917 0.7875 13.875C0.2625 12.6583 0 11.3667 0 10C0 8.61667 0.2625 7.32083 0.7875 6.1125C1.3125 4.90417 2.02917 3.84583 2.9375 2.9375C3.84583 2.02917 4.90833 1.3125 6.125 0.7875C7.34167 0.2625 8.63333 0 10 0C11.3833 0 12.6792 0.2625 13.8875 0.7875C15.0958 1.3125 16.1542 2.02917 17.0625 2.9375C17.9708 3.84583 18.6875 4.90417 19.2125 6.1125C19.7375 7.32083 20 8.61667 20 10C20 11.3667 19.7375 12.6583 19.2125 13.875C18.6875 15.0917 17.9708 16.1542 17.0625 17.0625C16.1542 17.9708 15.0958 18.6875 13.8875 19.2125C12.6792 19.7375 11.3833 20 10 20ZM10 17.95C10.4333 17.35 10.8083 16.725 11.125 16.075C11.4417 15.425 11.7 14.7333 11.9 14H8.1C8.3 14.7333 8.55833 15.425 8.875 16.075C9.19167 16.725 9.56667 17.35 10 17.95ZM7.4 17.55C7.1 17 6.8375 16.4292 6.6125 15.8375C6.3875 15.2458 6.2 14.6333 6.05 14H3.1C3.58333 14.8333 4.1875 15.5583 4.9125 16.175C5.6375 16.7917 6.46667 17.25 7.4 17.55ZM12.6 17.55C13.5333 17.25 14.3625 16.7917 15.0875 16.175C15.8125 15.5583 16.4167 14.8333 16.9 14H13.95C13.8 14.6333 13.6125 15.2458 13.3875 15.8375C13.1625 16.4292 12.9 17 12.6 17.55ZM2.25 12H5.65C5.6 11.6667 5.5625 11.3375 5.5375 11.0125C5.5125 10.6875 5.5 10.35 5.5 10C5.5 9.65 5.5125 9.3125 5.5375 8.9875C5.5625 8.6625 5.6 8.33333 5.65 8H2.25C2.16667 8.33333 2.10417 8.6625 2.0625 8.9875C2.02083 9.3125 2 9.65 2 10C2 10.35 2.02083 10.6875 2.0625 11.0125C2.10417 11.3375 2.16667 11.6667 2.25 12ZM7.65 12H12.35C12.4 11.6667 12.4375 11.3375 12.4625 11.0125C12.4875 10.6875 12.5 10.35 12.5 10C12.5 9.65 12.4875 9.3125 12.4625 8.9875C12.4375 8.6625 12.4 8.33333 12.35 8H7.65C7.6 8.33333 7.5625 8.6625 7.5375 8.9875C7.5125 9.3125 7.5 9.65 7.5 10C7.5 10.35 7.5125 10.6875 7.5375 11.0125C7.5625 11.3375 7.6 11.6667 7.65 12ZM14.35 12H17.75C17.8333 11.6667 17.8958 11.3375 17.9375 11.0125C17.9792 10.6875 18 10.35 18 10C18 9.65 17.9792 9.3125 17.9375 8.9875C17.8958 8.6625 17.8333 8.33333 17.75 8H14.35C14.4 8.33333 14.4375 8.6625 14.4625 8.9875C14.4875 9.3125 14.5 9.65 14.5 10C14.5 10.35 14.4875 10.6875 14.4625 11.0125C14.4375 11.3375 14.4 11.6667 14.35 12ZM13.95 6H16.9C16.4167 5.16667 15.8125 4.44167 15.0875 3.825C14.3625 3.20833 13.5333 2.75 12.6 2.45C12.9 3 13.1625 3.57083 13.3875 4.1625C13.6125 4.75417 13.8 5.36667 13.95 6ZM8.1 6H11.9C11.7 5.26667 11.4417 4.575 11.125 3.925C10.8083 3.275 10.4333 2.65 10 2.05C9.56667 2.65 9.19167 3.275 8.875 3.925C8.55833 4.575 8.3 5.26667 8.1 6ZM3.1 6H6.05C6.2 5.36667 6.3875 4.75417 6.6125 4.1625C6.8375 3.57083 7.1 3 7.4 2.45C6.46667 2.75 5.6375 3.20833 4.9125 3.825C4.1875 4.44167 3.58333 5.16667 3.1 6Z", +} diff --git a/docs/ENCRYPTION_FALLBACK.md b/docs/ENCRYPTION_FALLBACK.md new file mode 100644 index 00000000..27c19978 --- /dev/null +++ b/docs/ENCRYPTION_FALLBACK.md @@ -0,0 +1,110 @@ +# 密码加密回退策略 + +## 概述 + +HiDNS 使用 RSA-OAEP(通过 Web Crypto API)在密码传输到服务器之前进行加密。本文档描述加密不可用时的回退策略。 + +## 协议检测 + +| 协议 | 加密可用 | 行为 | +|------|---------|------| +| HTTPS | 是(Web Crypto API 可用) | 加密**必须**;失败则阻止登录 | +| HTTP | 否(非安全上下文中 Web Crypto API 不可用) | 加密**尝试**;失败则触发用户确认回退 | + +## 流程图 + +``` +登录尝试 + │ + ▼ +┌─────────────────────┐ +│ encryptPassword() │ +│ (RSA-OAEP) │ +└─────────┬───────────┘ + │ + ┌─────┴─────┐ + │ 成功? │ + └─────┬─────┘ + 是 │ 否 + │ │ + ▼ ▼ + 密文 ┌──────────────────────────┐ + 登录 │ 协议 = HTTPS? │ + └──────┬───────────────────┘ + 是 │ 否 + │ │ + ▼ ▼ + 阻止 显示警告弹窗 + 登录 (明文传输风险) + │ + ┌─────┴─────┐ + │ 用户 │ + │ 确认? │ + └─────┬─────┘ + 是 │ 否 + │ │ + ▼ ▼ + 明文 阻止 + 登录 登录 +``` + +## 安全考量 + +1. **HTTPS 模式**:密码在传输前始终进行 RSA 加密。加密失败(如服务器公钥不可用)会硬性阻止——用户无法继续登录。 + +2. **HTTP 模式**:Web Crypto API 的 `SubtleCrypto.encrypt()` 仅在安全上下文(HTTPS 或 localhost)中可用。在纯 HTTP 下,`encryptPassword()` 会抛出异常。回退策略允许用户在通过弹窗确认风险后,以明文方式传输密码继续登录。 + +3. **服务端接受**:服务端需配置为同时接受加密和明文密码(登录 API 中的 `encrypted` 布尔标志用于指示密码是否经过 RSA 加密)。 + +## 实现 + +### 客户端(`LoginCard.tsx`) + +```typescript +async function encryptWithFallback( + password: string, + confirmFn: () => Promise, + encryptionFailed: string, +): Promise<{ encrypted: string; plaintext: boolean }> { + try { + const encrypted = await encryptPassword(password); + return { encrypted, plaintext: false }; + } catch { + // HTTPS:加密失败是致命错误 + if (window.location.protocol === 'https:') { + throw new Error(encryptionFailed); + } + // HTTP:显示确认弹窗,等待用户选择 + const confirmed = await confirmFn(); + if (!confirmed) throw new Error(encryptionFailed); + return { encrypted: password, plaintext: true }; + } +} +``` + +### i18n 键 + +| 键 | 用途 | 示例(中文) | +|----|------|-------------| +| `login.httpWarning` | 弹窗正文 | "当前使用 HTTP 协议,密码将以明文传输,存在安全风险。是否继续?" | +| `login.httpWarningTitle` | 弹窗标题 | "不安全的连接" | +| `login.httpWarningConfirm` | 确认按钮 | "继续" | +| `login.httpWarningCancel` | 取消按钮 | "取消" | + +### 登录 API 标志 + +登录请求包含 `encrypted` 布尔值,告知服务端如何解析密码: + +```json +{ + "username": "admin", + "password": "...", + "encrypted": true // 使用明文回退时为 false +} +``` + +## 何时触发? + +- 用户通过 `http://`(非 `https://`)访问 HiDNS +- 服务端 RSA 公钥端点(`/api/auth/public-key`)不可达 +- Web Crypto API 不可用(旧版浏览器、非安全上下文)