Add comprehensive remote authentication system allowing users to log in from one device by authenticating from another trusted device. Features include: - Proof of Work (PoW) protection using PBKDF2-SHA512 to prevent abuse - Simple pairing codes (3 words) protected by dynamic PoW difficulty - Autocomplete pairing code input with error checking - Real-time WebSocket communication between devices Unlike device addition links and reset links with QR codes that only allow adding an authentication method, and that work offline over the duration of several days, this mechanism is strictly online, with 5 minute time limit.
223 lines
6.2 KiB
Vue
223 lines
6.2 KiB
Vue
<template>
|
|
<div class="app-shell">
|
|
<div v-if="status.show" class="global-status" style="display: block;">
|
|
<div :class="['status', status.type]">
|
|
{{ status.message }}
|
|
</div>
|
|
</div>
|
|
|
|
<main class="view-root">
|
|
<div class="surface surface--tight" style="max-width: 560px; margin: 0 auto; width: 100%;">
|
|
<header class="view-header" style="text-align: center;">
|
|
<h1>🔑 Registration</h1>
|
|
<p class="view-lede">
|
|
{{ subtitleMessage }}
|
|
</p>
|
|
</header>
|
|
|
|
<section class="section-block" v-if="initializing">
|
|
<div class="section-body center">
|
|
<p>Loading reset details…</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="section-block" v-else-if="!canRegister">
|
|
<div class="section-body center">
|
|
<div class="button-row center" style="justify-content: center;">
|
|
<button class="btn-secondary" @click="goHome">Return to sign-in</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="section-block" v-else>
|
|
<div class="section-body">
|
|
<label class="name-edit">
|
|
<span>👤 Name</span>
|
|
<input
|
|
type="text"
|
|
v-model="displayName"
|
|
:disabled="loading"
|
|
maxlength="64"
|
|
@keyup.enter="registerPasskey"
|
|
/>
|
|
</label>
|
|
<button
|
|
class="btn-primary"
|
|
:disabled="loading"
|
|
@click="registerPasskey"
|
|
>
|
|
{{ loading ? 'Registering…' : 'Register Passkey' }}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
|
import passkey from '@/utils/passkey'
|
|
import { getSettings, uiBasePath } from '@/utils/settings'
|
|
import { apiJson, ApiError, getUserFriendlyErrorMessage } from '@/utils/api'
|
|
|
|
const status = reactive({
|
|
show: false,
|
|
message: '',
|
|
type: 'info'
|
|
})
|
|
|
|
const initializing = ref(true)
|
|
const loading = ref(false)
|
|
const token = ref('')
|
|
const settings = ref(null)
|
|
const userInfo = ref(null)
|
|
const displayName = ref('')
|
|
const errorMessage = ref('')
|
|
let statusTimer = null
|
|
|
|
const sessionDescriptor = computed(() => userInfo.value?.session_type || 'your enrollment')
|
|
const subtitleMessage = computed(() => {
|
|
if (initializing.value) return 'Preparing your secure enrollment…'
|
|
if (!canRegister.value) return 'This authentication link is no longer valid.'
|
|
return `Finish up ${sessionDescriptor.value}. You may edit the name below if needed, and it will be saved to your passkey.`
|
|
})
|
|
|
|
const basePath = computed(() => uiBasePath())
|
|
|
|
const canRegister = computed(() => !!(token.value && userInfo.value))
|
|
|
|
function showMessage(message, type = 'info', duration = 3000) {
|
|
status.show = true
|
|
status.message = message
|
|
status.type = type
|
|
if (statusTimer) clearTimeout(statusTimer)
|
|
if (duration > 0) {
|
|
statusTimer = setTimeout(() => {
|
|
status.show = false
|
|
}, duration)
|
|
}
|
|
}
|
|
|
|
async function fetchSettings() {
|
|
try {
|
|
const data = await getSettings()
|
|
settings.value = data
|
|
if (data?.rp_name) document.title = `${data.rp_name} · Passkey Setup`
|
|
} catch (error) {
|
|
console.warn('Unable to load settings', error)
|
|
}
|
|
}
|
|
|
|
async function fetchUserInfo() {
|
|
if (!token.value) return
|
|
try {
|
|
userInfo.value = await apiJson(`/auth/api/user-info?reset=${encodeURIComponent(token.value)}`, {
|
|
method: 'POST'
|
|
})
|
|
displayName.value = userInfo.value?.user?.user_name || ''
|
|
} catch (error) {
|
|
console.error('Failed to load user info', error)
|
|
const message = error instanceof ApiError
|
|
? (error.data?.detail || 'The authentication link is invalid or expired.')
|
|
: getUserFriendlyErrorMessage(error)
|
|
errorMessage.value = message
|
|
}
|
|
}
|
|
|
|
async function registerPasskey() {
|
|
if (!canRegister.value || loading.value) return
|
|
loading.value = true
|
|
showMessage('Starting passkey registration…', 'info')
|
|
|
|
let result
|
|
try {
|
|
const nameValue = displayName.value.trim() || null
|
|
result = await passkey.register(token.value, nameValue)
|
|
} catch (error) {
|
|
loading.value = false
|
|
const message = error?.message || 'Passkey registration cancelled'
|
|
const cancelled = message === 'Passkey registration cancelled'
|
|
showMessage(cancelled ? message : `Registration failed: ${message}`, cancelled ? 'info' : 'error', 4000)
|
|
return
|
|
}
|
|
|
|
try {
|
|
await setSessionCookie(result)
|
|
} catch (error) {
|
|
loading.value = false
|
|
const message = error?.message || 'Failed to establish session'
|
|
showMessage(message, 'error', 4000)
|
|
return
|
|
}
|
|
|
|
showMessage('Passkey registered successfully!', 'success', 800)
|
|
setTimeout(() => { loading.value = false; goHome() }, 800)
|
|
}
|
|
|
|
async function setSessionCookie(result) {
|
|
if (!result?.session_token) {
|
|
throw new Error('Registration response missing session_token')
|
|
}
|
|
return await apiJson('/auth/api/set-session', {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${result.session_token}`
|
|
}
|
|
})
|
|
}
|
|
|
|
function goHome() {
|
|
const target = uiBasePath.value || '/auth/'
|
|
if (window.location.pathname !== target) {
|
|
history.replaceState(null, '', target)
|
|
}
|
|
window.location.reload()
|
|
}
|
|
|
|
function extractTokenFromPath() {
|
|
const segments = window.location.pathname.split('/').filter(Boolean)
|
|
if (!segments.length) return ''
|
|
const candidate = segments[segments.length - 1]
|
|
const prefix = segments.slice(0, -1)
|
|
if (prefix.length > 1) return ''
|
|
if (prefix.length === 1 && prefix[0] !== 'auth') return ''
|
|
if (!candidate.includes('.')) return ''
|
|
return candidate
|
|
}
|
|
|
|
onMounted(async () => {
|
|
token.value = extractTokenFromPath()
|
|
await fetchSettings()
|
|
if (!token.value) {
|
|
const message = 'Reset link is missing or malformed.'
|
|
errorMessage.value = message
|
|
showMessage(message, 'error', 0)
|
|
initializing.value = false
|
|
return
|
|
}
|
|
await fetchUserInfo()
|
|
initializing.value = false
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.center {
|
|
text-align: center;
|
|
}
|
|
|
|
.button-row.center {
|
|
display: flex;
|
|
justify-content: center;
|
|
}
|
|
|
|
.section-body {
|
|
gap: 1.25rem;
|
|
}
|
|
|
|
.name-edit span {
|
|
color: var(--color-text-muted);
|
|
font-size: 0.9rem;
|
|
}
|
|
</style>
|