mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
fix(frontend): support login autofill and reliable redirect
This commit is contained in:
@@ -131,13 +131,19 @@
|
|||||||
|
|
||||||
<!-- 登录表单 -->
|
<!-- 登录表单 -->
|
||||||
<form
|
<form
|
||||||
|
ref="loginFormEl"
|
||||||
|
name="login"
|
||||||
|
action="/api/auth/login"
|
||||||
|
method="post"
|
||||||
class="space-y-4"
|
class="space-y-4"
|
||||||
|
autocomplete="on"
|
||||||
|
data-form-type="login"
|
||||||
@submit.prevent="handleLogin"
|
@submit.prevent="handleLogin"
|
||||||
>
|
>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<Label
|
<Label
|
||||||
for="login-email"
|
for="username"
|
||||||
class="text-sm"
|
class="text-sm"
|
||||||
>
|
>
|
||||||
{{ emailLabel }}
|
{{ emailLabel }}
|
||||||
@@ -160,29 +166,35 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
id="login-email"
|
id="username"
|
||||||
v-model="form.email"
|
v-model="form.email"
|
||||||
type="text"
|
type="text"
|
||||||
|
name="username"
|
||||||
required
|
required
|
||||||
placeholder="用户名或邮箱"
|
placeholder="用户名或邮箱"
|
||||||
autocomplete="off"
|
autocomplete="username"
|
||||||
|
autocapitalize="none"
|
||||||
|
spellcheck="false"
|
||||||
|
:disable-autofill="false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label
|
<Label
|
||||||
for="login-password"
|
for="password"
|
||||||
class="text-sm"
|
class="text-sm"
|
||||||
>
|
>
|
||||||
密码
|
密码
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="login-password"
|
id="password"
|
||||||
v-model="form.password"
|
v-model="form.password"
|
||||||
type="password"
|
type="password"
|
||||||
|
name="password"
|
||||||
required
|
required
|
||||||
placeholder="输入密码"
|
placeholder="输入密码"
|
||||||
autocomplete="off"
|
autocomplete="current-password"
|
||||||
|
:disable-autofill="false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -288,6 +300,7 @@ const ldapEnabled = ref(false)
|
|||||||
const ldapExclusive = ref(false)
|
const ldapExclusive = ref(false)
|
||||||
|
|
||||||
const oauthProviders = ref<OAuthProviderInfo[]>([])
|
const oauthProviders = ref<OAuthProviderInfo[]>([])
|
||||||
|
const loginFormEl = ref<HTMLFormElement | null>(null)
|
||||||
|
|
||||||
// 保存用户的认证类型偏好
|
// 保存用户的认证类型偏好
|
||||||
watch(authType, (newType) => {
|
watch(authType, (newType) => {
|
||||||
@@ -328,30 +341,69 @@ function fillDemoAccount(type: 'admin' | 'user') {
|
|||||||
form.value.password = account.password
|
form.value.password = account.password
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin(event?: Event) {
|
||||||
if (!form.value.email || !form.value.password) {
|
const { email, password } = readCurrentLoginCredentials(event)
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
showWarning('请输入邮箱和密码')
|
showWarning('请输入邮箱和密码')
|
||||||
return
|
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) {
|
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('登录成功,正在跳转...')
|
showSuccess('登录成功,正在跳转...')
|
||||||
|
|
||||||
// 关闭对话框
|
// 关闭对话框
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
|
|
||||||
// 延迟一下让用户看到成功消息
|
|
||||||
setTimeout(() => {
|
|
||||||
// 根据用户角色跳转到不同的仪表盘
|
|
||||||
const targetPath = authStore.canAccessAdmin ? '/admin/dashboard' : '/dashboard'
|
|
||||||
router.push(targetPath)
|
|
||||||
}, 1000)
|
|
||||||
} else {
|
} else {
|
||||||
showError(authStore.error || '登录失败,请检查邮箱和密码')
|
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) {
|
function handleOAuthLogin(providerType: string) {
|
||||||
// 如果 sessionStorage 中没有 redirectPath(用户直接点击登录而非被守卫拦截),
|
// 如果 sessionStorage 中没有 redirectPath(用户直接点击登录而非被守卫拦截),
|
||||||
// 则不设置,让 AuthCallback 使用默认跳转逻辑
|
// 则不设置,让 AuthCallback 使用默认跳转逻辑
|
||||||
|
|||||||
@@ -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('登录成功,正在跳转...')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user