Drafting remote auth linking.

This commit is contained in:
Leo Vasanko
2025-12-06 16:43:47 +00:00
parent 2b7481cd58
commit 4da1127976
9 changed files with 1554 additions and 4 deletions
@@ -0,0 +1,216 @@
<template>
<div class="pairing-entry">
<div class="pairing-header">
<h3>{{ title }}</h3>
<p class="pairing-description">{{ description }}</p>
</div>
<form @submit.prevent="submitCode" class="pairing-form">
<div class="input-group">
<input
ref="inputRef"
v-model="code"
type="text"
:placeholder="placeholder"
:disabled="loading"
autocomplete="off"
autocapitalize="characters"
spellcheck="false"
class="pairing-input"
@input="handleInput"
/>
<button
type="submit"
:disabled="!isValid || loading"
class="btn-primary"
>
{{ loading ? 'Connecting…' : 'Connect' }}
</button>
</div>
<p v-if="error" class="error-message">{{ error }}</p>
</form>
<!-- Success state -->
<div v-if="completed" class="success-section">
<p class="success-message"> {{ completedMessage }}</p>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { startAuthentication } from '@simplewebauthn/browser'
import aWebSocket from '@/utils/awaitable-websocket'
import { getSettings } from '@/utils/settings'
const props = defineProps({
title: { type: String, default: 'Help Another Device Sign In' },
description: { type: String, default: 'Enter the code shown on the device that needs to sign in.' },
placeholder: { type: String, default: 'Enter code' }
})
const emit = defineEmits(['completed', 'error', 'cancelled'])
const inputRef = ref(null)
const code = ref('')
const loading = ref(false)
const error = ref(null)
const completed = ref(false)
const completedMessage = ref('')
let ws = null
// Valid if we have 3 words separated by dots or spaces
const isValid = computed(() => {
const trimmed = code.value.trim()
if (!trimmed) return false
const words = trimmed.split(/[.\s]+/).filter(w => w.length > 0)
return words.length >= 3
})
function handleInput() {
error.value = null
}
async function submitCode() {
if (!isValid.value || loading.value) return
loading.value = true
error.value = null
try {
const settings = await getSettings()
const authHost = settings?.auth_host
// Normalize the code: lowercase words joined by dots
const normalizedCode = code.value.trim().toLowerCase().split(/[.\s]+/).filter(w => w).join('.')
const wsPath = `/auth/ws/remote-auth/pair/${encodeURIComponent(normalizedCode)}`
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
ws = await aWebSocket(wsUrl)
// Receive authentication options
const res = await ws.receive_json()
if (res.status) {
throw new Error(res.detail || `Connection failed: ${res.status}`)
}
// Perform WebAuthn authentication
const authResponse = await startAuthentication(res.optionsJSON || res)
ws.send_json(authResponse)
// Wait for confirmation
const result = await ws.receive_json()
if (result.status === 'success') {
completed.value = true
completedMessage.value = result.message || 'The other device is now logged in.'
emit('completed')
} else {
throw new Error(result.detail || 'Authentication failed')
}
} catch (err) {
console.error('Pairing error:', err)
const message = err.name === 'NotAllowedError'
? 'Passkey authentication was cancelled'
: (err.message || 'Failed to connect')
error.value = message
emit('error', message)
} finally {
loading.value = false
if (ws) {
ws.close()
ws = null
}
}
}
function reset() {
code.value = ''
error.value = null
completed.value = false
completedMessage.value = ''
}
onMounted(() => {
inputRef.value?.focus()
})
defineExpose({ reset })
</script>
<style scoped>
.pairing-entry {
display: flex;
flex-direction: column;
gap: 1rem;
}
.pairing-header h3 {
margin: 0 0 0.25rem;
font-size: 1rem;
font-weight: 600;
}
.pairing-description {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.pairing-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.input-group {
display: flex;
gap: 0.5rem;
}
.pairing-input {
flex: 1;
padding: 0.625rem 0.75rem;
font-size: 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 4px);
background: var(--color-surface);
color: var(--color-text);
}
.pairing-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-alpha, rgba(59, 130, 246, 0.2));
}
.pairing-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.pairing-input::placeholder {
color: var(--color-text-muted);
opacity: 0.6;
}
.error-message {
margin: 0;
font-size: 0.875rem;
color: var(--color-error, #ef4444);
}
.success-section {
padding: 0.75rem;
background: var(--color-success-bg, rgba(16, 185, 129, 0.1));
border-radius: var(--radius-sm, 4px);
}
.success-message {
margin: 0;
font-size: 0.95rem;
color: var(--color-success, #10b981);
}
</style>
+32
View File
@@ -52,6 +52,23 @@
section-description="Review where you're signed in and end any sessions you no longer recognize."
/>
<section class="section-block">
<div class="section-header">
<h2>Help Another Device Sign In</h2>
<p class="section-description">Enter a code from another device to sign it in using your passkey.</p>
</div>
<div class="section-body">
<PairingCodeEntry
ref="pairingEntry"
title=""
description=""
placeholder="word word word"
@completed="handlePairingCompleted"
@error="handlePairingError"
/>
</div>
</section>
<Modal v-if="showNameDialog" @close="showNameDialog = false">
<h3>Edit Display Name</h3>
<form @submit.prevent="saveName" class="modal-form">
@@ -102,6 +119,7 @@ import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.vue'
import SessionList from '@/components/SessionList.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import PairingCodeEntry from '@/components/PairingCodeEntry.vue'
import { useAuthStore } from '@/stores/auth'
import { adminUiPath, makeUiHref } from '@/utils/settings'
import passkey from '@/utils/passkey'
@@ -116,6 +134,7 @@ const newName = ref('')
const saving = ref(false)
const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null)
const pairingEntry = ref(null)
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.user?.user_name || '' })
@@ -138,6 +157,19 @@ const addNewCredential = async () => {
}
}
const handlePairingCompleted = () => {
authStore.showMessage('The other device is now signed in!', 'success', 4000)
// Reset the form after a delay
setTimeout(() => pairingEntry.value?.reset(), 3000)
}
const handlePairingError = (message) => {
// Error is already shown in the component, optionally show global message for severe errors
if (!message.includes('cancelled')) {
authStore.showMessage(message, 'error', 4000)
}
}
const handleDelete = async (credential) => {
const credentialId = credential?.credential_uuid
if (!credentialId) return
@@ -0,0 +1,224 @@
<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">
<header class="view-header center">
<h1>📱 Remote Login</h1>
<p class="view-lede">{{ subtitleMessage }}</p>
</header>
<section class="section-block">
<div class="section-body center">
<!-- Loading/Initializing -->
<div v-if="initializing" class="loading-section">
<p>Connecting</p>
</div>
<!-- Error state -->
<div v-else-if="error" class="error-section">
<p class="error-message">{{ error }}</p>
<div class="button-row center">
<button class="btn-secondary" @click="goHome">Return to sign-in</button>
</div>
</div>
<!-- Completed state -->
<div v-else-if="completed" class="success-section">
<p class="success-message"> {{ completedMessage }}</p>
<p class="help-text">You can now close this window.</p>
<div class="button-row center">
<button class="btn-secondary" @click="goHome">Go to Profile</button>
</div>
</div>
<!-- Ready to authenticate -->
<div v-else class="auth-section">
<p class="help-text">
Use your passkey on this device to log in on another device.
</p>
<div class="button-row center">
<button class="btn-secondary" :disabled="loading" @click="$emit('back')">Cancel</button>
<button class="btn-primary" :disabled="loading" @click="authenticate">
{{ loading ? 'Authenticating' : 'Authenticate with Passkey' }}
</button>
</div>
</div>
</div>
</section>
</div>
</main>
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { startAuthentication } from '@simplewebauthn/browser'
import aWebSocket from '@/utils/awaitable-websocket'
import { getSettings, uiBasePath } from '@/utils/settings'
const props = defineProps({
token: { type: String, required: true }
})
const emit = defineEmits(['back', 'completed'])
const status = reactive({ show: false, message: '', type: 'info' })
const initializing = ref(true)
const loading = ref(false)
const settings = ref(null)
const error = ref(null)
const completed = ref(false)
const completedMessage = ref('')
let statusTimer = null
let ws = null
const subtitleMessage = computed(() => {
if (initializing.value) return 'Preparing secure remote authentication…'
if (error.value) return 'This remote login link is no longer valid.'
if (completed.value) return 'Remote login successful!'
return 'Authenticate here to log in on another device.'
})
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} · Remote Login`
} catch (err) {
console.warn('Unable to load settings', err)
}
}
async function authenticate() {
if (loading.value || !props.token) return
loading.value = true
showMessage('Starting authentication…', 'info')
try {
// Connect to the remote auth completion WebSocket
const authHost = settings.value?.auth_host
const wsPath = `/auth/ws/remote-auth/complete/${encodeURIComponent(props.token)}`
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
ws = await aWebSocket(wsUrl)
// Receive authentication options
const res = await ws.receive_json()
if (res.status) {
throw new Error(res.detail || `Authentication failed: ${res.status}`)
}
// Perform WebAuthn authentication (options are in optionsJSON field)
const authResponse = await startAuthentication(res.optionsJSON || res)
ws.send_json(authResponse)
// Wait for confirmation
const result = await ws.receive_json()
if (result.status === 'success') {
completed.value = true
completedMessage.value = result.message || 'The other device is now logged in.'
showMessage('Authentication successful!', 'success', 3000)
emit('completed')
} else {
throw new Error(result.detail || 'Remote authentication failed')
}
} catch (err) {
console.error('Remote authentication error:', err)
const message = err.name === 'NotAllowedError'
? 'Passkey authentication cancelled'
: (err.message || 'Authentication failed')
const cancelled = message === 'Passkey authentication cancelled'
showMessage(message, cancelled ? 'info' : 'error', 4000)
if (!cancelled) {
error.value = message
}
} finally {
loading.value = false
if (ws) {
ws.close()
ws = null
}
}
}
function goHome() {
const target = uiBasePath() || '/auth/'
window.location.href = target
}
onMounted(async () => {
await fetchSettings()
initializing.value = false
})
onUnmounted(() => {
if (ws) {
ws.close()
ws = null
}
})
</script>
<style scoped>
.center { text-align: center; }
.button-row.center { display: flex; justify-content: center; gap: 0.75rem; }
main.view-root {
min-height: 100vh;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
}
.surface.surface--tight {
max-width: 520px;
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.75rem;
}
.help-text {
color: var(--color-text-muted);
font-size: 0.95rem;
margin: 0.5rem 0 1rem;
}
.success-message {
font-size: 1.1rem;
font-weight: 500;
color: var(--color-success, #10b981);
margin-bottom: 0.5rem;
}
.error-message {
font-size: 0.95rem;
color: var(--color-error, #ef4444);
margin-bottom: 1rem;
}
.loading-section, .auth-section, .success-section, .error-section {
display: flex;
flex-direction: column;
align-items: center;
}
</style>
@@ -0,0 +1,417 @@
<template>
<!-- Dialog mode (modal overlay) -->
<div v-if="!inline && active" class="dialog-overlay" @keydown.esc.prevent="cancel">
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="remoteTitle">
<div class="reg-header-row">
<h2 id="remoteTitle" class="reg-title">📱 Login from Another Device</h2>
<button class="icon-btn" @click="cancel" aria-label="Close"></button>
</div>
<!-- QR/Link display while waiting -->
<div v-if="url && !completed && !error" class="device-link-section">
<div class="qr-container">
<div v-if="pairingCode" class="pairing-code-section">
<p class="pairing-label">Enter this code on a device with your passkey:</p>
<div class="pairing-code" @click="copyCode">{{ displayCode }}</div>
</div>
<div class="qr-divider"><span>or scan</span></div>
<a :href="url" @click.prevent="copy" class="qr-link">
<canvas ref="qrCanvas" class="qr-code"></canvas>
</a>
<p class="reg-help"><small>{{ expirationMessage }}</small></p>
</div>
<div class="waiting-indicator">
<div class="spinner"></div>
<span>Waiting for authentication</span>
</div>
</div>
<!-- Success state -->
<div v-else-if="completed" class="success-section">
<p class="success-message"> Authenticated successfully!</p>
</div>
<!-- Error state -->
<div v-else-if="error" class="error-section">
<p class="error-message">{{ error }}</p>
</div>
<!-- Loading state -->
<div v-else class="loading-section">
<div class="spinner"></div>
<p>Generating remote login link</p>
</div>
<div class="reg-actions">
<button v-if="!completed" class="btn-secondary" @click="cancel">Cancel</button>
<button v-if="url && !completed && !error" class="btn-primary" @click="copy">Copy Link</button>
<button v-if="error" class="btn-primary" @click="retry">Try Again</button>
</div>
</div>
</div>
<!-- Inline mode (embedded in page) -->
<div v-else-if="inline && active" class="registration-inline-wrapper">
<div class="registration-inline-block section-block">
<div class="section-header">
<h2 class="inline-heading">📱 Login from Another Device</h2>
</div>
<div class="section-body">
<!-- QR/Link display while waiting -->
<div v-if="url && !completed && !error" class="device-link-section">
<div class="qr-container">
<div v-if="pairingCode" class="pairing-code-section">
<p class="pairing-label">Enter this code on a device with your passkey:</p>
<div class="pairing-code" @click="copyCode">{{ displayCode }}</div>
</div>
<div class="qr-divider"><span>or scan</span></div>
<a :href="url" @click.prevent="copy" class="qr-link">
<canvas ref="qrCanvas" class="qr-code"></canvas>
</a>
<p class="reg-help"><small>{{ expirationMessage }}</small></p>
</div>
<div class="waiting-indicator">
<div class="spinner"></div>
<span>Waiting for authentication</span>
</div>
</div>
<!-- Success state -->
<div v-else-if="completed" class="success-section">
<p class="success-message"> Authenticated successfully!</p>
</div>
<!-- Error state -->
<div v-else-if="error" class="error-section">
<p class="error-message">{{ error }}</p>
</div>
<!-- Loading state -->
<div v-else class="loading-section">
<div class="spinner"></div>
<p>Generating remote login link</p>
</div>
<div class="button-row" style="margin-top:1rem;">
<button v-if="url && !completed && !error" class="btn-primary" @click="copy">Copy Link</button>
<button v-if="!completed" class="btn-secondary" @click="cancel">Cancel</button>
<button v-if="error" class="btn-primary" @click="retry">Try Again</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import QRCode from 'qrcode/lib/browser'
import aWebSocket from '@/utils/awaitable-websocket'
import { getSettings } from '@/utils/settings'
import { formatDate } from '@/utils/helpers'
const props = defineProps({
active: { type: Boolean, default: false },
inline: { type: Boolean, default: false },
autoCopy: { type: Boolean, default: false }
})
const emit = defineEmits(['authenticated', 'cancelled', 'error', 'close', 'copied'])
const url = ref(null)
const pairingCode = ref(null)
const expires = ref(null)
const qrCanvas = ref(null)
const completed = ref(false)
const error = ref(null)
let ws = null
const displayUrl = computed(() => url.value ? url.value.replace(/^[^:]+:\/\//, '') : '')
// Display pairing code as space-separated words for readability
const displayCode = computed(() => pairingCode.value ? pairingCode.value.replace(/\./g, ' ') : '')
const expirationMessage = computed(() => {
if (!expires.value) return ''
const timeStr = formatDate(expires.value)
return `⚠️ Expires ${timeStr.startsWith('In ') ? timeStr.substring(3) : timeStr}.`
})
async function startRemoteAuth() {
error.value = null
completed.value = false
url.value = null
pairingCode.value = null
expires.value = null
try {
// Get auth_host from settings to construct proper WebSocket URL
const settings = await getSettings()
const authHost = settings?.auth_host
const wsPath = '/auth/ws/remote-auth/request'
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
ws = await aWebSocket(wsUrl)
// Receive the remote auth token, pairing code, and URL
const res = await ws.receive_json()
if (res.status) {
throw new Error(res.detail || `Failed to create remote auth request: ${res.status}`)
}
url.value = res.url
pairingCode.value = res.pairing_code
expires.value = res.expires
await nextTick()
drawQR()
if (props.autoCopy) copy()
// Now wait for authentication
waitForAuth()
} catch (err) {
console.error('Remote auth error:', err)
error.value = err.message || 'Failed to start remote authentication'
emit('error', error.value)
cleanup()
}
}
async function waitForAuth() {
if (!ws) return
try {
const result = await ws.receive_json()
if (result.status === 'authenticated') {
completed.value = true
emit('authenticated', {
session_token: result.session_token,
user_uuid: result.user_uuid
})
} else if (result.status === 'expired' || result.status === 'cancelled') {
error.value = result.detail || 'Remote authentication was cancelled or expired'
emit('cancelled')
} else {
error.value = result.detail || 'Remote authentication failed'
emit('error', error.value)
}
} catch (err) {
if (!completed.value) {
error.value = err.message || 'Connection lost'
emit('error', error.value)
}
} finally {
cleanup()
}
}
function drawQR() {
if (!url.value) return
nextTick(() => {
if (!qrCanvas.value) return
QRCode.toCanvas(qrCanvas.value, url.value, { scale: 8 }, err => {
if (err) console.error('QR code error:', err)
})
})
}
async function copy() {
if (!url.value) return
try {
await navigator.clipboard.writeText(url.value)
emit('copied', url.value)
} catch (_) {
/* ignore */
}
}
async function copyCode() {
if (!pairingCode.value) return
try {
await navigator.clipboard.writeText(pairingCode.value)
} catch (_) {
/* ignore */
}
}
function cancel() {
if (ws) {
try {
ws.send_json({ action: 'cancel' })
} catch (_) {
/* ignore */
}
}
cleanup()
emit('cancelled')
emit('close')
}
function retry() {
error.value = null
startRemoteAuth()
}
function cleanup() {
if (ws) {
try {
ws.close()
} catch (_) {
/* ignore */
}
ws = null
}
}
watch(() => props.active, (newVal) => {
if (newVal) {
startRemoteAuth()
} else {
cleanup()
// Reset state when deactivated
url.value = null
pairingCode.value = null
expires.value = null
completed.value = false
error.value = null
}
})
onMounted(() => {
if (props.active) {
startRemoteAuth()
}
})
onUnmounted(() => {
cleanup()
})
watch(url, () => drawQR(), { flush: 'post' })
</script>
<style scoped>
.icon-btn { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: .6; }
.icon-btn:hover { opacity: 1; }
.qr-link { text-decoration: none; color: inherit; }
.reg-header-row { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-bottom: .75rem; }
.reg-title { margin: 0; font-size: 1.25rem; font-weight: 600; }
.device-dialog { background: var(--color-surface); padding: 1.25rem 1.25rem 1rem; border-radius: var(--radius-md); max-width: 480px; width: 100%; box-shadow: 0 6px 28px rgba(0,0,0,.25); }
.qr-container { display: flex; flex-direction: column; align-items: center; gap: .5rem; }
.qr-code { display: block; }
.reg-help { margin-top: .5rem; margin-bottom: .75rem; font-size: .85rem; line-height: 1.25rem; text-align: center; }
.reg-actions { display: flex; justify-content: flex-end; gap: .5rem; margin-top: .25rem; }
.registration-inline-block .qr-container { align-items: flex-start; }
.registration-inline-block .reg-help { text-align: left; }
.pairing-code-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background: var(--color-surface-hover, rgba(0,0,0,0.05));
border-radius: var(--radius-sm, 4px);
width: 100%;
}
.pairing-label {
font-size: 0.875rem;
color: var(--color-text-muted);
margin: 0;
}
.pairing-code {
font-size: 1.5rem;
font-weight: 600;
letter-spacing: 0.1em;
color: var(--color-primary);
cursor: pointer;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.15s;
text-transform: capitalize;
}
.pairing-code:hover {
background: var(--color-surface-hover, rgba(0,0,0,0.1));
}
.qr-divider {
display: flex;
align-items: center;
width: 100%;
margin: 0.5rem 0;
color: var(--color-text-muted);
font-size: 0.8rem;
}
.qr-divider::before,
.qr-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--color-border);
}
.qr-divider span {
padding: 0 0.75rem;
}
.waiting-indicator {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 0.75rem;
background: var(--color-surface-hover, rgba(0,0,0,0.05));
border-radius: var(--radius-sm, 4px);
font-size: 0.9rem;
color: var(--color-text-muted);
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 2rem;
}
.loading-section .spinner {
width: 32px;
height: 32px;
border-width: 3px;
}
.success-section, .error-section {
padding: 1.5rem;
text-align: center;
}
.success-message {
font-size: 1.1rem;
font-weight: 500;
color: var(--color-success, #10b981);
}
.error-message {
font-size: 0.95rem;
color: var(--color-error, #ef4444);
}
</style>
+40 -1
View File
@@ -23,12 +23,16 @@
:is-authenticated="isAuthenticated"
:authenticate="authenticateUser"
:logout="logoutUser"
:mode="mode">
:mode="mode"
:start-remote-auth="startRemoteAuth">
<!-- Default actions -->
<button class="btn-secondary" :disabled="loading" @click="$emit('back')">Back</button>
<button v-if="canAuthenticate" class="btn-primary" :disabled="loading" @click="authenticateUser">
{{ loading ? (mode === 'reauth' ? 'Verifying' : 'Signing in') : (mode === 'reauth' ? 'Verify' : 'Login') }}
</button>
<button v-if="canAuthenticate && mode !== 'reauth'" class="btn-secondary" :disabled="loading" @click="startRemoteAuth">
Use Another Device
</button>
<button v-if="isAuthenticated && mode !== 'reauth'" class="btn-danger" :disabled="loading" @click="logoutUser">Logout</button>
<button v-if="isAuthenticated && mode !== 'reauth'" class="btn-primary" :disabled="loading" @click="openProfile">Profile</button>
</slot>
@@ -37,6 +41,15 @@
</section>
</div>
</main>
<!-- Remote Auth Modal -->
<RemoteAuthLinkModal
:active="showRemoteAuth"
@authenticated="handleRemoteAuthenticated"
@cancelled="showRemoteAuth = false"
@close="showRemoteAuth = false"
@error="handleRemoteAuthError"
/>
</div>
</template>
@@ -45,6 +58,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey'
import { getSettings } from '@/utils/settings'
import { fetchJson, getUserFriendlyErrorMessage } from '@/utils/api'
import RemoteAuthLinkModal from '@/components/RemoteAuthLinkModal.vue'
const props = defineProps({
mode: {
@@ -62,6 +76,7 @@ const loading = ref(false)
const settings = ref(null)
const userInfo = ref(null)
const currentView = ref('initial') // 'initial', 'login', 'forbidden'
const showRemoteAuth = ref(false)
let statusTimer = null
const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
@@ -196,6 +211,30 @@ async function setSessionCookie(result) {
})
}
// Remote authentication from another device
function startRemoteAuth() {
showRemoteAuth.value = true
}
async function handleRemoteAuthenticated(result) {
showRemoteAuth.value = false
showMessage('Authenticated from another device!', 'success', 2000)
try {
await setSessionCookie(result)
} catch (error) {
const message = error?.message || 'Failed to establish session'
showMessage(message, 'error', 4000)
emit('auth-error', { message, cancelled: false })
return
}
emit('authenticated', result)
}
function handleRemoteAuthError(errorMsg) {
showRemoteAuth.value = false
showMessage(errorMsg || 'Remote authentication failed', 'error', 4000)
}
onMounted(async () => {
await fetchSettings()
await fetchUserInfo()