Merge remote-tracking branch 'origin/pr-492'

This commit is contained in:
fawney19
2026-05-18 14:52:29 +08:00
2 changed files with 308 additions and 16 deletions

View File

@@ -131,13 +131,19 @@
<!-- 登录表单 -->
<form
ref="loginFormEl"
name="login"
action="/api/auth/login"
method="post"
class="space-y-4"
autocomplete="on"
data-form-type="login"
@submit.prevent="handleLogin"
>
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label
for="login-email"
for="username"
class="text-sm"
>
{{ emailLabel }}
@@ -160,29 +166,35 @@
</button>
</div>
<Input
id="login-email"
id="username"
v-model="form.email"
type="text"
name="username"
required
placeholder="用户名或邮箱"
autocomplete="off"
autocomplete="username"
autocapitalize="none"
spellcheck="false"
:disable-autofill="false"
/>
</div>
<div class="space-y-1.5">
<Label
for="login-password"
for="password"
class="text-sm"
>
密码
</Label>
<Input
id="login-password"
id="password"
v-model="form.password"
type="password"
name="password"
required
placeholder="输入密码"
autocomplete="off"
autocomplete="current-password"
:disable-autofill="false"
/>
</div>
@@ -288,6 +300,7 @@ const ldapEnabled = ref(false)
const ldapExclusive = ref(false)
const oauthProviders = ref<OAuthProviderInfo[]>([])
const loginFormEl = ref<HTMLFormElement | null>(null)
// 保存用户的认证类型偏好
watch(authType, (newType) => {
@@ -328,30 +341,69 @@ function fillDemoAccount(type: 'admin' | 'user') {
form.value.password = account.password
}
async function handleLogin() {
if (!form.value.email || !form.value.password) {
async function handleLogin(event?: Event) {
const { email, password } = readCurrentLoginCredentials(event)
if (!email || !password) {
showWarning('请输入邮箱和密码')
return
}
const success = await authStore.login(form.value.email, form.value.password, authType.value)
const success = await authStore.login(email, password, authType.value)
if (success) {
const targetPath = consumeStoredRedirectPath() ?? (authStore.canAccessAdmin ? '/admin/dashboard' : '/dashboard')
try {
const navigationFailure = await router.push(targetPath)
if (navigationFailure) {
throw navigationFailure
}
} catch {
showError('登录成功,但跳转失败,请刷新页面或手动进入控制台')
return
}
showSuccess('登录成功,正在跳转...')
// 关闭对话框
isOpen.value = false
// 延迟一下让用户看到成功消息
setTimeout(() => {
// 根据用户角色跳转到不同的仪表盘
const targetPath = authStore.canAccessAdmin ? '/admin/dashboard' : '/dashboard'
router.push(targetPath)
}, 1000)
} else {
showError(authStore.error || '登录失败,请检查邮箱和密码')
}
}
function readCurrentLoginCredentials(event?: Event): { email: string; password: string } {
const formElement = event?.currentTarget instanceof HTMLFormElement
? event.currentTarget
: loginFormEl.value
const emailInput = formElement?.elements.namedItem('username')
const passwordInput = formElement?.elements.namedItem('password')
const email = emailInput instanceof HTMLInputElement
? emailInput.value.trim()
: form.value.email.trim()
const password = passwordInput instanceof HTMLInputElement
? passwordInput.value
: form.value.password
form.value.email = email
form.value.password = password
return { email, password }
}
function consumeStoredRedirectPath(): string | null {
const redirectPath = sessionStorage.getItem('redirectPath')
if (redirectPath) {
sessionStorage.removeItem('redirectPath')
}
if (!redirectPath || redirectPath === '/' || !redirectPath.startsWith('/') || redirectPath.startsWith('//')) {
return null
}
return redirectPath
}
function handleOAuthLogin(providerType: string) {
// 如果 sessionStorage 中没有 redirectPath用户直接点击登录而非被守卫拦截
// 则不设置,让 AuthCallback 使用默认跳转逻辑

View File

@@ -0,0 +1,240 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
import LoginDialog from '../LoginDialog.vue'
const authStoreMock = vi.hoisted(() => ({
loading: false,
error: '',
canAccessAdmin: false,
login: vi.fn(),
}))
const routerPushMock = vi.hoisted(() => vi.fn())
const toastMocks = vi.hoisted(() => ({
success: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
}))
const authApiMocks = vi.hoisted(() => ({
getRegistrationSettings: vi.fn(),
getAuthSettings: vi.fn(),
}))
const oauthApiMocks = vi.hoisted(() => ({
getProviders: vi.fn(),
}))
vi.mock('vue-router', () => ({
useRouter: () => ({
push: routerPushMock,
}),
}))
vi.mock('@/stores/auth', () => ({
useAuthStore: () => authStoreMock,
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => toastMocks,
}))
vi.mock('@/composables/useSiteInfo', () => ({
useSiteInfo: () => ({
siteName: 'Aether',
}),
}))
vi.mock('@/config/demo', () => ({
isDemoMode: () => false,
DEMO_ACCOUNTS: {
admin: { email: 'admin@demo.aether.io', password: 'demo123' },
user: { email: 'user@demo.aether.io', password: 'demo123' },
},
}))
vi.mock('@/api/auth', () => ({
authApi: authApiMocks,
}))
vi.mock('@/api/oauth', () => ({
oauthApi: oauthApiMocks,
}))
vi.mock('@/utils/deviceId', () => ({
getClientDeviceId: () => 'device-123',
}))
vi.mock('@/utils/url', () => ({
getApiUrl: (path: string) => path,
}))
vi.mock('@/utils/oauth-icons', () => ({
getOAuthIcon: () => '',
}))
vi.mock('../RegisterDialog.vue', () => ({
default: defineComponent({
name: 'RegisterDialogStub',
setup() {
return () => null
},
}),
}))
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
return {
Dialog: defineComponent({
name: 'DialogStub',
props: {
modelValue: { type: Boolean, default: false },
},
emits: ['update:modelValue'],
setup(props, { slots }) {
return () => props.modelValue ? h('div', { 'data-testid': 'dialog' }, slots.default?.()) : null
},
}),
}
})
vi.mock('@/components/ui/button.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'ButtonStub',
props: {
disabled: { type: Boolean, default: false },
type: { type: String, default: 'button' },
},
setup(props, { attrs, slots }) {
return () => h('button', {
...attrs,
type: props.type,
disabled: props.disabled,
}, slots.default?.())
},
}),
}
})
vi.mock('@/components/ui/label.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'LabelStub',
setup(_props, { attrs, slots }) {
return () => h('label', attrs, slots.default?.())
},
}),
}
})
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function mountLoginDialog() {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(LoginDialog, {
modelValue: true,
'onUpdate:modelValue': vi.fn(),
})
app.mount(root)
mountedApps.push({ app, root })
return root
}
async function settle() {
for (let index = 0; index < 4; index += 1) {
await Promise.resolve()
await nextTick()
}
}
beforeEach(() => {
authStoreMock.loading = false
authStoreMock.error = ''
authStoreMock.canAccessAdmin = false
authStoreMock.login.mockReset()
routerPushMock.mockReset()
toastMocks.success.mockReset()
toastMocks.warning.mockReset()
toastMocks.error.mockReset()
authApiMocks.getRegistrationSettings.mockResolvedValue({
enable_registration: false,
require_email_verification: false,
email_configured: true,
password_policy_level: 'weak',
turnstile_enabled: false,
turnstile_site_key: null,
})
authApiMocks.getAuthSettings.mockResolvedValue({
local_enabled: true,
ldap_enabled: false,
ldap_exclusive: false,
})
oauthApiMocks.getProviders.mockResolvedValue([])
sessionStorage.clear()
localStorage.clear()
})
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
document.body.innerHTML = ''
sessionStorage.clear()
localStorage.clear()
})
describe('LoginDialog password manager contract', () => {
it('exposes standard login form and field autocomplete metadata', async () => {
const root = mountLoginDialog()
await settle()
const form = root.querySelector('form')
expect(form?.getAttribute('name')).toBe('login')
expect(form?.getAttribute('action')).toBe('/api/auth/login')
expect(form?.getAttribute('method')).toBe('post')
expect(form?.getAttribute('autocomplete')).toBe('on')
expect(form?.getAttribute('data-form-type')).toBe('login')
const username = root.querySelector<HTMLInputElement>('input[name="username"]')
const password = root.querySelector<HTMLInputElement>('input[name="password"]')
expect(username?.id).toBe('username')
expect(username?.getAttribute('autocomplete')).toBe('username')
expect(username?.getAttribute('autocapitalize')).toBe('none')
expect(username?.getAttribute('spellcheck')).toBe('false')
expect(password?.id).toBe('password')
expect(password?.type).toBe('password')
expect(password?.getAttribute('autocomplete')).toBe('current-password')
})
it('submits DOM-filled credentials and awaits router navigation without timer delay', async () => {
authStoreMock.login.mockResolvedValue(true)
routerPushMock.mockResolvedValue(undefined)
sessionStorage.setItem('redirectPath', '/admin/dashboard')
const root = mountLoginDialog()
await settle()
const form = root.querySelector('form')
const username = root.querySelector<HTMLInputElement>('input[name="username"]')
const password = root.querySelector<HTMLInputElement>('input[name="password"]')
expect(form).not.toBeNull()
expect(username).not.toBeNull()
expect(password).not.toBeNull()
username!.value = ' admin@example.com '
password!.value = 'secret-from-manager'
form!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
await settle()
expect(authStoreMock.login).toHaveBeenCalledWith('admin@example.com', 'secret-from-manager', 'local')
expect(routerPushMock).toHaveBeenCalledWith('/admin/dashboard')
expect(sessionStorage.getItem('redirectPath')).toBeNull()
expect(toastMocks.success).toHaveBeenCalledWith('登录成功,正在跳转...')
})
})