Pass login page

This commit is contained in:
2025-07-24 18:45:38 +08:00
parent 31ac45026e
commit 5a24c31d43
5 changed files with 364 additions and 4 deletions

View File

@@ -17,7 +17,12 @@ const router = createRouter({
path: '/spells/:word',
name: 'spells',
component: () => import('../views/spells.vue'),
}
},
{
path: '/login',
name: 'login',
component: () => import('../views/login.vue'),
},
],
})

View File

@@ -0,0 +1,342 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { NCard, NSpace, NInput, NButton, NSpin, NAlert, NProgress } from 'naive-ui'
import { useRouter } from 'vue-router'
import FingerprintJS from '@fingerprintjs/fingerprintjs'
// State management
const stage = ref<'find-account' | 'select-factor' | 'enter-code' | 'token-exchange'>(
'find-account',
)
const isLoading = ref(false)
const error = ref<string | null>(null)
// Stage 1: Find Account
const accountIdentifier = ref('')
const deviceId = ref('')
// Stage 2 & 3: Challenge
const challenge = ref<any>(null)
const factors = ref<any[]>([])
const selectedFactorId = ref<string | null>(null)
const password = ref('') // Used for password or verification code
const router = useRouter()
// Generate deviceId based on browser fingerprint
onMounted(async () => {
const fp = await FingerprintJS.load()
const result = await fp.get()
deviceId.value = result.visitorId
localStorage.setItem('deviceId', deviceId.value)
})
const selectedFactor = computed(() => {
if (!selectedFactorId.value) return null
return factors.value.find((f) => f.id === selectedFactorId.value)
})
async function handleFindAccount() {
if (!accountIdentifier.value) {
error.value = 'Please enter your email or username.'
return
}
isLoading.value = true
error.value = null
try {
const response = await fetch('/api/auth/challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
platform: 1,
account: accountIdentifier.value,
device_id: deviceId.value,
}),
})
if (!response.ok) {
const message = await response.text()
throw new Error(message || 'Account not found.')
}
challenge.value = await response.json()
await getFactors()
stage.value = 'select-factor'
} catch (e: any) {
error.value = e.message
} finally {
isLoading.value = false
}
}
async function getFactors() {
isLoading.value = true
error.value = null
try {
const response = await fetch(`/api/auth/challenge/${challenge.value.id}/factors`)
if (!response.ok) {
throw new Error('Could not fetch authentication factors.')
}
const availableFactors = await response.json()
factors.value = availableFactors.filter(
(f: any) => !challenge.value.blacklist_factors.includes(f.id),
)
if (factors.value.length > 0) {
selectedFactorId.value = null // Let user choose
} else if (challenge.value.step_remain > 0) {
error.value =
'No more available authentication factors, but authentication is not complete. Please contact support.'
}
} catch (e: any) {
error.value = e.message
} finally {
isLoading.value = false
}
}
async function requestVerificationCode(hint: string | null) {
if (!selectedFactorId.value) return
const isResend = stage.value === 'enter-code'
if (isResend) isLoading.value = true
error.value = null
try {
const response = await fetch(
`/api/auth/challenge/${challenge.value.id}/factors/${selectedFactorId.value}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(hint),
},
)
if (!response.ok) {
const message = await response.text()
throw new Error(message || 'Failed to send code.')
}
} catch (e: any) {
error.value = e.message
throw e // Rethrow to be handled by caller
} finally {
if (isResend) isLoading.value = false
}
}
async function handleFactorSelected() {
if (!selectedFactor.value) {
error.value = 'Please select an authentication method.'
return
}
// For password or TOTP, just move to the next step
if (selectedFactor.value.type === 0 || selectedFactor.value.type === 2) {
stage.value = 'enter-code'
return
}
// For email, send the code first
if (selectedFactor.value.type === 1) {
isLoading.value = true
error.value = null
try {
await requestVerificationCode(selectedFactor.value.contact)
stage.value = 'enter-code'
} catch {
// Error is already set by requestVerificationCode
} finally {
isLoading.value = false
}
}
}
async function handleVerifyFactor() {
if (!selectedFactorId.value || !password.value) {
error.value = 'Please enter your password/code.'
return
}
isLoading.value = true
error.value = null
try {
const response = await fetch(`/api/auth/challenge/${challenge.value.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
factor_id: selectedFactorId.value,
password: password.value,
}),
})
if (!response.ok) {
const message = await response.text()
throw new Error(message || 'Verification failed.')
}
challenge.value = await response.json()
password.value = ''
if (challenge.value.step_remain === 0) {
stage.value = 'token-exchange'
await exchangeToken()
} else {
await getFactors()
stage.value = 'select-factor' // MFA step
}
} catch (e: any) {
error.value = e.message
} finally {
isLoading.value = false
}
}
async function exchangeToken() {
isLoading.value = true
error.value = null
try {
const response = await fetch('/api/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code: challenge.value.id,
}),
})
if (!response.ok) {
const message = await response.text()
throw new Error(message || 'Token exchange failed.')
}
const { token } = await response.json()
localStorage.setItem('authToken', token)
await router.push('/')
} catch (e: any) {
error.value = e.message
stage.value = 'select-factor' // Go back if token exchange fails
} finally {
isLoading.value = false
}
}
function getFactorName(factorType: number) {
switch (factorType) {
case 0:
return 'Password'
case 1:
return 'Email'
case 2:
return 'Authenticator App'
default:
return 'Unknown Factor'
}
}
</script>
<template>
<div class="flex items-center justify-center h-full">
<n-card class="w-full max-w-md" title="Login">
<n-spin :show="isLoading">
<n-space vertical>
<!-- Stage 1: Find Account -->
<div v-if="stage === 'find-account'">
<p>Welcome back!</p>
<p class="mb-4">Login with your Solarpass.</p>
<p class="mb-4">Enter your account identifier to continue.</p>
<n-input
v-model:value="accountIdentifier"
placeholder="Email or Username"
size="large"
@keydown.enter="handleFindAccount"
class="mb-4"
/>
<n-button type="primary" block class="mt-4" size="large" @click="handleFindAccount">
Continue
</n-button>
</div>
<!-- Stage 2: Select Factor -->
<div v-if="stage === 'select-factor' && challenge">
<div class="flex items-center mb-4 gap-3">
<span class="flex-shrink-1">Completeness</span>
<n-progress
type="line"
:percentage="(1 - challenge.step_remain / challenge.step_total) * 100"
indicator-placement="inside"
class="flex-1"
/>
</div>
<div class="flex flex-col gap-3">
<n-card
v-for="factor in factors"
:key="factor.id"
size="small"
hoverable
class="cursor-pointer"
@click="
() => {
selectedFactorId = factor.id
handleFactorSelected()
}
"
:title="getFactorName(factor.type)"
></n-card>
</div>
<p class="text-center text-xs opacity-75 mt-3">Select a method to authenticate</p>
</div>
<!-- Stage 3: Enter Code -->
<div v-if="stage === 'enter-code' && selectedFactor">
<h3 class="mb-3">
Enter the {{ selectedFactor.type === 0 ? 'password' : 'verification code' }} to
continue.
</h3>
<p v-if="selectedFactor.type === 1">
A code has been sent to {{ selectedFactor.contact }}.
</p>
<p v-if="selectedFactor.type === 2">Enter the code from your authenticator app.</p>
<n-input
v-model:value="password"
type="password"
show-password-on="click"
:placeholder="selectedFactor.type === 0 ? 'Password' : 'Code'"
size="large"
class="mb-4"
@keydown.enter="handleVerifyFactor"
/>
<n-space justify="end">
<n-button
v-if="selectedFactor.type === 1"
text
@click="requestVerificationCode(selectedFactor.contact)"
>
Resend Code
</n-button>
</n-space>
<n-button type="primary" block class="mt-4" size="large" @click="handleVerifyFactor">
Verify
</n-button>
</div>
<!-- Stage 4: Token Exchange -->
<div v-if="stage === 'token-exchange'">
<h3 class="mb-4">Finalizing Login</h3>
<n-spin />
</div>
<n-alert
v-if="error"
title="Error"
type="error"
closable
@after-hide="error = null"
class="mt-2"
>
{{ error }}
</n-alert>
</n-space>
</n-spin>
</n-card>
</div>
</template>