418 lines
11 KiB
Vue
418 lines
11 KiB
Vue
<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>
|