Files
paskia/frontend/src/components/RemoteAuthLinkModal.vue
T

431 lines
12 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 { dec as b64dec, enc as b64enc } from '@/utils/base64url'
import { getSettings } from '@/utils/settings'
import { formatDate } from '@/utils/helpers'
import { solvePoW } from '@/utils/pow'
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)
// First message is PoW challenge
const powChallenge = await ws.receive_json()
if (powChallenge.pow) {
const challenge = b64dec(powChallenge.pow.challenge)
const powStart = performance.now()
const nonces = await solvePoW(challenge, powChallenge.pow.work)
const powTime = performance.now() - powStart
console.log(`PoW solved: ${powChallenge.pow.work} work units in ${(powTime / 1000).toFixed(3)}s`)
ws.send_json({ pow: b64enc(nonces) })
}
// 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>