Files
replypier/frontend/src/views/LoginView.vue
T

1054 lines
32 KiB
Vue
Raw Normal View History

<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { authService } from '@/services/auth'
import { resolveLandingRoute } from '@/router/landing'
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
import { useForgotPassword } from '@/composables/useForgotPassword'
import InstallPrompt from '@/components/pwa/InstallPrompt.vue'
import { apiPath, getApiUrl } from '@/config/api'
import type { AxiosError } from 'axios'
interface ErrorResponse {
detail: string
}
const router = useRouter()
const { loadModule } = useEnterpriseFeatures()
const trackLogin = async (method: string) => {
const mod = await loadModule('/src/modules/enterprise/utils/analytics.ts') as any
mod?.trackLogin?.(method)
}
const email = ref('')
const password = ref('')
const error = ref('')
const isLoading = ref(false)
// Check if enterprise module is available
const { hasEnterpriseModule } = useEnterpriseFeatures()
// Forgot password composable - only initialize if enterprise module is available
const showForgotPasswordModal = ref(false)
const forgotPassword = hasEnterpriseModule ? useForgotPassword() : null
// Destructure with fallbacks for when enterprise module is not available
const isForgotPasswordLoading = forgotPassword?.isLoading ?? ref(false)
const forgotPasswordError = forgotPassword?.error ?? ref('')
const forgotPasswordSuccess = forgotPassword?.success ?? ref('')
const forgotPasswordStep = forgotPassword?.currentStep ?? ref(1)
const forgotPasswordEmail = forgotPassword?.email ?? ref('')
const forgotPasswordOtp = forgotPassword?.otp ?? ref('')
const newPassword = forgotPassword?.newPassword ?? ref('')
const confirmPassword = forgotPassword?.confirmPassword ?? ref('')
const passwordValidation = forgotPassword?.passwordValidation ?? ref({
score: 0,
hasMinLength: false,
hasUpperCase: false,
hasLowerCase: false,
hasNumber: false,
hasSpecialChar: false
})
const requestPasswordReset = forgotPassword?.requestPasswordReset ?? (() => Promise.resolve(false))
const verifyAndResetPassword = forgotPassword?.verifyAndResetPassword ?? (() => Promise.resolve(false))
const resetForgotPasswordForm = forgotPassword?.resetForm ?? (() => {})
const goBackToEmailStep = forgotPassword?.goBackToEmailStep ?? (() => {})
// One ordered candidate list, shared with '/', the catch-all and the 403 page.
// It used to end at '/403', which is not somewhere to land — and the route was
// not even registered, so the user silently ended up on /ai-agents instead.
const getInitialRoute = () => resolveLandingRoute()
const handleLogin = async () => {
try {
isLoading.value = true
error.value = ''
await authService.login(email.value, password.value)
trackLogin('email')
// Check if this is Shopify flow (new managed installation)
const urlParams = new URLSearchParams(window.location.search)
const isShopifyFlow = urlParams.get('shopify_flow') === '1'
const shopId = urlParams.get('shop_id')
const returnTo = urlParams.get('return_to')
if (isShopifyFlow && shopId) {
console.log('🔗 Shopify flow detected, linking organization')
try {
// If return_to is specified, redirect there
if (returnTo) {
console.log('📍 Redirecting to return_to:', returnTo)
window.location.href = returnTo
return
}
} catch (linkError: any) {
console.error('❌ Failed to link organization:', linkError)
error.value = linkError.response?.data?.detail || '关联组织失败'
isLoading.value = false
return
}
}
// Check if this is an OLD embedded Shopify login (opened in popup) - for backward compatibility
const isEmbedded = router.currentRoute.value.query.embedded === 'true'
const legacyShopId = router.currentRoute.value.query.shop_id as string
if (isEmbedded && legacyShopId && window.opener) {
// This is a popup for Shopify embedded app (old flow)
console.log('Login successful in popup, notifying parent window')
// Show success state
isLoading.value = true
error.value = ''
// Notify the parent window of successful login
if (window.opener && !window.opener.closed) {
window.opener.postMessage(
{ type: 'login_success', shop_id: legacyShopId },
window.location.origin
)
// Close the popup after a brief delay to ensure message is sent
setTimeout(() => {
window.close()
}, 300)
} else {
// If opener is gone, just close the window
setTimeout(() => {
window.close()
}, 300)
}
return
}
// Check for Shopify flow (new simplified flow)
if (router.currentRoute.value.query.shopify_flow === '1') {
const shop = router.currentRoute.value.query.shop as string
const shopId = router.currentRoute.value.query.shop_id as string
const host = router.currentRoute.value.query.host as string
const returnTo = router.currentRoute.value.query.return_to as string
// Redirect to auth complete page
const query: any = { shop, shop_id: shopId, host }
if (returnTo) {
query.return_to = returnTo
}
router.push({
path: '/shopify/auth-complete',
query
})
return
}
// Check if there's a Shopify redirect pending in localStorage (legacy flow)
const shopifyRedirectData = localStorage.getItem('shopifyRedirect')
if (shopifyRedirectData) {
try {
const redirectInfo = JSON.parse(shopifyRedirectData)
// Clear the stored redirect
localStorage.removeItem('shopifyRedirect')
// Build the full URL with all query parameters
const queryParams = new URLSearchParams()
for (const [key, value] of Object.entries(redirectInfo)) {
queryParams.append(key, value as string)
}
// Redirect to the backend Shopify auth endpoint
window.location.href = apiPath(`/shopify/auth?${queryParams.toString()}`)
return // Don't do the normal navigation
} catch (e) {
console.error('Failed to parse shopifyRedirect data:', e)
// Clear invalid data
localStorage.removeItem('shopifyRedirect')
}
}
// Check for redirect query parameter (internal frontend route)
const redirectPath = router.currentRoute.value.query.redirect as string
if (redirectPath) {
// If it's an internal route (starts with /), use router.push
if (redirectPath.startsWith('/')) {
router.push(redirectPath)
return
}
// Otherwise treat as API redirect (legacy behavior)
window.location.href = `${getApiUrl()}${redirectPath}`
return
}
// Determine initial route based on permissions
const initialRoute = getInitialRoute()
router.push(initialRoute)
} catch (err) {
console.log(err)
// authService.login rethrows the backend's detail as a plain Error, so
// reading only the axios response showed "Login failed" for everything.
const detail = (err as AxiosError<ErrorResponse>).response?.data?.detail
error.value = (typeof detail === 'string' && detail) || (err instanceof Error && err.message) || '登录失败'
console.error('Login error:', err)
} finally {
isLoading.value = false
}
}
const navigateToSignup = () => {
// Preserve embedded, shop_id, return_to, redirect query params if present
const isEmbedded = router.currentRoute.value.query.embedded
const shopId = router.currentRoute.value.query.shop_id
const returnTo = router.currentRoute.value.query.return_to
const shopifyFlow = router.currentRoute.value.query.shopify_flow
const redirect = router.currentRoute.value.query.redirect
const query: any = {}
if (isEmbedded) query.embedded = isEmbedded
if (shopId) query.shop_id = shopId
if (returnTo) query.return_to = returnTo
if (shopifyFlow) query.shopify_flow = shopifyFlow
if (redirect) query.redirect = redirect
if (Object.keys(query).length > 0) {
console.log('Navigating to signup with params:', query)
router.push({
path: '/signup',
query
})
} else {
router.push('/signup')
}
}
const openForgotPasswordModal = () => {
resetForgotPasswordForm()
showForgotPasswordModal.value = true
}
const closeForgotPasswordModal = () => {
showForgotPasswordModal.value = false
resetForgotPasswordForm()
}
const handleRequestPasswordReset = async () => {
const success = await requestPasswordReset()
// Keep modal open to proceed to step 2
}
const handleVerifyAndResetPassword = async () => {
const success = await verifyAndResetPassword()
if (success) {
// Wait a moment to show success message, then close modal
setTimeout(() => {
closeForgotPasswordModal()
// Optionally pre-fill the email in login form
email.value = forgotPasswordEmail.value
}, 2000)
}
}
</script>
<template>
<div class="auth-page">
<!-- Left: form panel -->
<div class="form-panel">
<!-- Logo mark -->
<div class="auth-logo">
<div class="logo-mark">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
<span class="logo-word">ChatterMate</span>
</div>
<h1 class="auth-title">欢迎回来</h1>
<p class="auth-sub">登录以继续使用你的仪表盘</p>
<form @submit.prevent="handleLogin" class="auth-form">
<div class="field">
<label for="email">邮箱</label>
<input id="email" v-model="email" type="email" required placeholder="you@company.com" autocomplete="email" />
</div>
<div class="field">
<label for="password">密码</label>
<input id="password" v-model="password" type="password" required placeholder="••••••••" autocomplete="current-password" />
<a v-if="hasEnterpriseModule" href="#" @click.prevent="openForgotPasswordModal" class="forgot-link">忘记密码?</a>
</div>
<div v-if="error" class="auth-error" role="alert">{{ error }}</div>
<button type="submit" class="auth-submit" :disabled="isLoading">
<span v-if="isLoading">{{ router.currentRoute.value.query.embedded === 'true' ? '连接中…' : '登录中…' }}</span>
<span v-else>登录</span>
</button>
<p v-if="hasEnterpriseModule" class="signup-prompt">
还没有账号?
<a href="#" @click.prevent="navigateToSignup" class="signup-link">注册</a>
</p>
</form>
<div class="install-hint-slot">
<InstallPrompt />
</div>
</div>
<!-- Right: brand panel with aurora -->
<div class="brand-panel">
<div class="aurora-blob blob-lime"></div>
<div class="aurora-blob blob-purple"></div>
<div class="aurora-blob blob-teal"></div>
<div class="brand-copy">
<!-- Siri orb -->
<div class="orb">
<div class="orb-glow"></div>
<div class="orb-gradient"></div>
<div class="orb-core"></div>
<div class="orb-ring"></div>
</div>
<div class="brand-badge">
<span class="badge-dot"></span>
开源 · MCP 原生
</div>
<h2>会自我学习的<em>客服。</em></h2>
<p class="brand-lede">读取你的知识库,适配任意聊天界面,可转接人工客服,还能通过开放的 MCP 回应其他 AI 客服。</p>
<ul class="feature-list">
<li><span class="check">✓</span> 知识库自动学习 — 拖入 PDF 或 URL 即可</li>
<li><span class="check">✓</span> 转接人工时保留完整上下文</li>
<li><span class="check">✓</span> 自托管或云端部署 — 使用你自己的模型</li>
</ul>
</div>
</div>
<!-- Forgot Password Modal - only show if enterprise module is available -->
<div v-if="hasEnterpriseModule && showForgotPasswordModal" class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h2>{{ forgotPasswordStep === 1 ? '重置密码' : '验证并重置' }}</h2>
<button class="close-btn" @click="closeForgotPasswordModal" aria-label="关闭">×</button>
</div>
<div class="modal-body">
<!-- Step 1: Request OTP -->
<div v-if="forgotPasswordStep === 1" class="forgot-password-step">
<p class="step-description">输入你的邮箱,我们会发送验证码以重置密码。</p>
<div class="form-group">
<label for="forgot-email">邮箱地址</label>
<div class="input-wrapper">
<input
id="forgot-email"
v-model="forgotPasswordEmail"
type="email"
required
placeholder="输入邮箱"
:disabled="isForgotPasswordLoading"
/>
</div>
</div>
<div v-if="forgotPasswordError" class="error-message" role="alert">
{{ forgotPasswordError }}
</div>
<div v-if="forgotPasswordSuccess" class="success-message" role="status">
{{ forgotPasswordSuccess }}
</div>
<button
class="modal-submit-btn"
@click="handleRequestPasswordReset"
:disabled="isForgotPasswordLoading || !forgotPasswordEmail"
>
{{ isForgotPasswordLoading ? '发送中…' : '发送验证码' }}
</button>
</div>
<!-- Step 2: Verify OTP and Reset Password -->
<div v-if="forgotPasswordStep === 2" class="forgot-password-step">
<p class="step-description">输入发送到你邮箱的验证码和新密码。</p>
<div class="form-group">
<label for="forgot-otp">验证码</label>
<div class="input-wrapper">
<input
id="forgot-otp"
v-model="forgotPasswordOtp"
type="text"
required
placeholder="输入 6 位验证码"
maxlength="6"
:disabled="isForgotPasswordLoading"
/>
</div>
</div>
<div class="form-group">
<label for="new-password">新密码</label>
<div class="input-wrapper">
<input
id="new-password"
v-model="newPassword"
type="password"
required
placeholder="输入新密码"
:disabled="isForgotPasswordLoading"
/>
</div>
<div class="password-requirements">
<p class="requirements-title">密码需包含:</p>
<ul>
<li :class="{ valid: passwordValidation.hasMinLength }">至少 8 个字符</li>
<li :class="{ valid: passwordValidation.hasUpperCase }">包含大写字母</li>
<li :class="{ valid: passwordValidation.hasLowerCase }">包含小写字母</li>
<li :class="{ valid: passwordValidation.hasNumber }">包含数字</li>
<li :class="{ valid: passwordValidation.hasSpecialChar }">包含特殊字符(!@#$%^&*)</li>
</ul>
</div>
</div>
<div class="form-group">
<label for="confirm-password">确认密码</label>
<div class="input-wrapper">
<input
id="confirm-password"
v-model="confirmPassword"
type="password"
required
placeholder="再次输入新密码"
:disabled="isForgotPasswordLoading"
/>
</div>
</div>
<div v-if="forgotPasswordError" class="error-message" role="alert">
{{ forgotPasswordError }}
</div>
<div v-if="forgotPasswordSuccess" class="success-message" role="status">
{{ forgotPasswordSuccess }}
</div>
<div class="modal-actions">
<button
class="modal-back-btn"
@click="goBackToEmailStep"
:disabled="isForgotPasswordLoading"
>
返回
</button>
<button
class="modal-submit-btn"
@click="handleVerifyAndResetPassword"
:disabled="isForgotPasswordLoading || !forgotPasswordOtp || !newPassword || !confirmPassword"
>
{{ isForgotPasswordLoading ? '重置中…' : '重置密码' }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Auth page is always dark — do not inherit app theme */
.auth-page {
min-height: 100vh;
display: grid;
grid-template-columns: 1.02fr .98fr;
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
}
/* ── Form panel ── */
.form-panel {
display: flex;
flex-direction: column;
justify-content: center;
padding: 60px 56px;
background: var(--bg);
min-height: 100vh;
min-height: 100dvh;
}
/* Install hint (design: dashed card under the form) — mobile only */
.install-hint-slot {
display: none;
margin-top: var(--space-lg);
padding-bottom: var(--safe-bottom);
}
@media (max-width: 768px) {
.install-hint-slot {
display: block;
}
}
.auth-logo {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 44px;
}
.logo-mark {
width: 32px;
height: 32px;
background: var(--accent-solid);
border-radius: 10px 10px 10px 2px;
display: flex;
align-items: center;
justify-content: center;
gap: 3.5px;
flex-shrink: 0;
}
.dot {
width: 4.5px;
height: 4.5px;
background: var(--on-accent);
border-radius: 50%;
}
.logo-word {
font-family: var(--font-display);
font-weight: 700;
font-size: 18px;
letter-spacing: -0.01em;
color: var(--text);
}
.auth-title {
font-family: var(--font-display);
font-size: 40px;
font-weight: 700;
letter-spacing: -0.03em;
color: var(--text);
margin-bottom: 10px;
line-height: 1.1;
}
.auth-sub {
color: var(--muted);
font-size: 15px;
margin-bottom: 36px;
}
/* Form */
.auth-form {
display: flex;
flex-direction: column;
gap: 20px;
max-width: 400px;
}
.field {
display: flex;
flex-direction: column;
gap: 9px;
}
.field-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.field label {
font-size: 13.5px;
font-weight: 500;
color: var(--text3);
}
.field input {
width: 100%;
padding: 14px 16px;
background: var(--o04);
border: 1px solid var(--o12);
border-radius: 12px;
color: var(--text);
font-family: var(--font-sans);
font-size: 15px;
transition: border-color 0.18s, box-shadow 0.18s;
}
.field input::placeholder { color: var(--faint); }
.field input:focus {
outline: none;
border-color: var(--accent-ink);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-ink) 15%, transparent);
}
.field input:-webkit-autofill,
.field input:-webkit-autofill:hover,
.field input:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0 1000px var(--bg2) inset !important;
-webkit-text-fill-color: var(--text) !important;
caret-color: var(--text);
border: 1px solid var(--o12) !important;
transition: background-color 9999s ease-in-out 0s;
}
.forgot-link {
align-self: flex-end;
margin-top: 2px;
font-size: 13px;
color: var(--accent-ink);
text-decoration: none;
}
.forgot-link:hover { text-decoration: underline; }
.auth-error {
color: var(--c-coral);
background: color-mix(in srgb, var(--c-coral) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--c-coral) 20%, transparent);
border-radius: 10px;
padding: 10px 14px;
font-size: 13.5px;
}
.auth-submit {
width: 100%;
padding: 15px;
background: var(--accent-solid);
color: var(--on-accent-solid);
border: none;
border-radius: 12px;
font-family: var(--font-sans);
font-weight: 600;
font-size: 15px;
cursor: pointer;
transition: opacity 0.18s;
}
.auth-submit:hover:not(:disabled) { opacity: 0.88; }
.auth-submit:disabled { opacity: 0.45; cursor: not-allowed; }
.signup-prompt {
text-align: center;
font-size: 14px;
color: var(--muted2);
}
.signup-link {
color: var(--accent-ink);
text-decoration: none;
font-weight: 500;
}
.signup-link:hover { text-decoration: underline; }
/* ── Brand panel ── */
.brand-panel {
position: relative;
background: linear-gradient(160deg, var(--bg-elevated), var(--bg-deep));
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
padding: 56px 6vw;
min-height: 100vh;
border-left: 1px solid var(--o06);
}
/* Aurora blobs */
.aurora-blob {
position: absolute;
border-radius: 50%;
filter: blur(80px);
animation: cm-aurora 14s ease-in-out infinite;
}
.blob-lime {
width: 420px;
height: 420px;
background: radial-gradient(circle, color-mix(in srgb, var(--accent-solid) 32%, transparent), color-mix(in srgb, var(--accent-solid) 6%, transparent));
top: -80px;
right: -60px;
animation-duration: 16s;
}
.blob-purple {
width: 360px;
height: 360px;
background: radial-gradient(circle, color-mix(in srgb, var(--c-purple) 28%, transparent), color-mix(in srgb, var(--c-purple) 4%, transparent));
top: 20%;
left: -80px;
animation-duration: 20s;
animation-delay: -5s;
}
.blob-teal {
width: 300px;
height: 300px;
background: radial-gradient(circle, color-mix(in srgb, var(--c-teal) 22%, transparent), color-mix(in srgb, var(--c-teal) 3%, transparent));
bottom: 15%;
right: 10%;
animation-duration: 18s;
animation-delay: -9s;
}
/* Brand copy */
.brand-copy {
position: relative;
z-index: 1;
max-width: 460px;
}
/* Siri orb */
.orb {
position: relative;
width: 120px;
height: 120px;
margin-bottom: 38px;
animation: cm-float 7s ease-in-out infinite;
}
.orb-glow {
position: absolute;
inset: -36px;
border-radius: 50%;
background: radial-gradient(circle, color-mix(in srgb, var(--accent-solid) 20%, transparent), transparent 70%);
filter: blur(10px);
}
.orb-gradient {
position: absolute;
inset: 0;
border-radius: 50%;
background: conic-gradient(from 0deg, var(--accent-solid), var(--c-purple), var(--c-teal), var(--c-coral), var(--accent-solid));
filter: blur(6px);
animation: cm-spin 6s linear infinite;
}
.orb-core {
position: absolute;
inset: 24px;
border-radius: 50%;
background: radial-gradient(circle at 40% 35%, color-mix(in srgb, var(--text) 92%, transparent), color-mix(in srgb, var(--text) 12%, transparent) 55%, transparent 72%);
animation: cm-pulse 2.6s ease-in-out infinite;
}
.orb-ring {
position: absolute;
inset: 0;
border-radius: 50%;
box-shadow: inset 0 0 26px color-mix(in srgb, var(--bg-deep) 55%, transparent);
}
/* Badge pill */
.brand-badge {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 7px 14px;
border: 1px solid var(--o12);
border-radius: 999px;
background: var(--o03);
font-family: var(--font-mono);
font-size: 12px;
color: var(--text3);
margin-bottom: 26px;
}
.badge-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent-solid);
box-shadow: 0 0 10px var(--accent-ink);
animation: cm-pulse 2.6s ease-in-out infinite;
}
.brand-copy h2 {
font-family: var(--font-display);
font-size: 42px;
font-weight: 700;
letter-spacing: -0.03em;
color: var(--text);
line-height: 1.06;
margin: 0 0 18px;
}
.brand-copy h2 em {
font-style: normal;
color: var(--accent-ink);
}
.brand-lede {
font-size: 17px;
line-height: 1.6;
color: var(--muted);
margin: 0 0 30px;
}
.feature-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.feature-list li {
display: flex;
align-items: center;
gap: 12px;
font-size: 15px;
color: var(--text3);
}
.check {
color: var(--accent-ink);
font-weight: 700;
font-size: 14px;
}
/* ── Responsive ── */
@media (max-width: 1024px) {
.auth-page { grid-template-columns: 1fr; }
.brand-panel { display: none; }
}
@media (max-width: 600px) {
.form-panel { padding: 40px 28px; }
.auth-title { font-size: 30px; }
}
/* Modal Overlay */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(5,6,9,.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-content {
background: var(--surface);
border: 1px solid var(--o10);
border-radius: 20px;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 50px rgba(0,0,0,.5);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--o08);
}
.modal-header h2 {
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: var(--text);
}
.close-btn {
background: none;
border: 1px solid var(--o12);
border-radius: 8px;
font-size: 1.5rem;
color: var(--muted2);
cursor: pointer;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
transition: 0.18s;
}
.close-btn:hover {
background: var(--o06);
color: var(--text);
}
.modal-body {
padding: 1.5rem;
}
.forgot-password-step {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.step-description {
color: var(--muted);
margin: 0;
line-height: 1.5;
font-size: 14px;
}
.success-message {
color: #0f9d6e;
text-align: center;
padding: 0.75rem;
background: rgba(15,157,110,.1);
border-radius: 10px;
font-size: 0.875rem;
margin: 0;
}
/* Password requirements styling matching signup */
.password-requirements {
margin-top: 0.5rem;
}
.password-requirements .requirements-title {
margin: 0 0 0.25rem 0;
font-size: 0.75rem;
color: var(--muted2);
}
.password-requirements ul {
margin: 0;
padding-left: 1rem;
}
.password-requirements li {
font-size: 0.8125rem;
color: var(--muted);
margin: 0.125rem 0;
}
.password-requirements li.valid {
color: var(--success-color, #10b981);
}
/* Modal uses the same dark form-field style as the auth form */
.form-group { margin-bottom: 0; }
.form-group label {
display: block;
margin-bottom: 9px;
font-size: 13.5px;
font-weight: 500;
color: var(--text3);
}
.input-wrapper input {
width: 100%;
padding: 13px 15px;
background: var(--o04);
border: 1px solid var(--o12);
border-radius: 12px;
color: var(--text);
font-family: var(--font-sans);
font-size: 14px;
transition: border-color 0.18s, box-shadow 0.18s;
}
.input-wrapper input::placeholder { color: var(--faint); }
.input-wrapper input:focus {
outline: none;
border-color: var(--accent-ink);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-ink) 15%, transparent);
}
.input-wrapper input:disabled { opacity: 0.5; cursor: not-allowed; }
.error-message {
color: var(--c-coral);
background: color-mix(in srgb, var(--c-coral) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--c-coral) 20%, transparent);
border-radius: 10px;
padding: 10px 14px;
font-size: 13.5px;
}
.modal-submit-btn {
width: 100%;
padding: 13px;
background: var(--accent-solid);
color: var(--on-accent-solid);
border: none;
border-radius: 12px;
font-family: var(--font-sans);
font-weight: 600;
font-size: 15px;
cursor: pointer;
transition: opacity 0.18s;
}
.modal-submit-btn:hover:not(:disabled) { opacity: 0.88; }
.modal-submit-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.modal-actions {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
.modal-back-btn {
flex: 1;
padding: 13px;
background: var(--o06);
color: var(--text3);
border: 1px solid var(--o12);
border-radius: 12px;
font-family: var(--font-sans);
font-weight: 600;
font-size: 15px;
cursor: pointer;
transition: 0.18s;
}
.modal-back-btn:hover:not(:disabled) {
background: var(--o10);
color: var(--text);
}
.modal-back-btn:disabled { opacity: 0.5; cursor: not-allowed; }
@media (max-width: 640px) {
.modal-content { max-width: 100%; margin: 1rem; }
.modal-header, .modal-body { padding: 1rem; }
}
</style>