Drafting remote auth linking.
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user