Drafting remote auth linking.
This commit is contained in:
@@ -1,5 +1,14 @@
|
||||
<template>
|
||||
<!-- Remote auth completion mode -->
|
||||
<RemoteAuthComplete
|
||||
v-if="remoteAuthToken"
|
||||
:token="remoteAuthToken"
|
||||
@back="handleBack"
|
||||
@completed="handleRemoteCompleted"
|
||||
/>
|
||||
<!-- Normal restricted auth mode -->
|
||||
<RestrictedAuth
|
||||
v-else
|
||||
:mode="authMode"
|
||||
@authenticated="handleAuthenticated"
|
||||
@back="handleBack"
|
||||
@@ -7,8 +16,25 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import RestrictedAuth from '@/components/RestrictedAuth.vue'
|
||||
import RemoteAuthComplete from '@/components/RemoteAuthComplete.vue'
|
||||
|
||||
// Check if this is a remote auth URL: /remote/{token} or /auth/remote/{token}
|
||||
const remoteAuthToken = ref(null)
|
||||
|
||||
function extractRemoteToken() {
|
||||
const path = window.location.pathname
|
||||
const match = path.match(/\/(?:auth\/)?remote\/([^/]+)$/)
|
||||
if (match) {
|
||||
const token = match[1]
|
||||
// Validate it looks like a passphrase (contains dots)
|
||||
if (token.includes('.')) {
|
||||
return token
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Detect mode from URL hash fragment
|
||||
const authMode = computed(() => {
|
||||
@@ -39,7 +65,15 @@ function handleBack() {
|
||||
})
|
||||
}
|
||||
|
||||
function handleRemoteCompleted() {
|
||||
// Remote auth completed - the other device is now logged in
|
||||
// This device doesn't need to do anything special
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Check for remote auth token in URL
|
||||
remoteAuthToken.value = extractRemoteToken()
|
||||
|
||||
postToParent({
|
||||
type: 'auth-ready'
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user