Massive refactoring of remote auth and related functionality. (WIP)

This commit is contained in:
Leo Vasanko
2025-12-08 22:39:57 +00:00
parent a54d872b46
commit 99c60f0e16
23 changed files with 2645 additions and 2043 deletions
@@ -0,0 +1,692 @@
<template>
<div class="remote-auth-inline">
<!-- Success state -->
<div v-if="completed" class="success-section">
<p class="success-message"> {{ successMessage }}</p>
</div>
<!-- Error state -->
<div v-else-if="error" class="error-section">
<p class="error-message">{{ error }}</p>
<button class="btn-primary" @click="retry" style="margin-top: 0.75rem;">Try Again</button>
</div>
<!-- Connecting phase -->
<div v-else-if="phase === 'connecting'" class="auth-display">
<div class="auth-content">
<div class="pairing-code-section">
<p class="pairing-label">Enter the code words:</p>
<div class="slot-machine" aria-hidden="true">
<div class="slot-reel" v-for="(word, index) in animatedWords" :key="index">
<div class="slot-word">{{ word }}</div>
</div>
</div>
<p class="site-url">{{ siteUrlDisplay }}</p>
</div>
<div class="qr-section">
<div class="qr-code qr-placeholder"></div>
</div>
</div>
<div class="waiting-indicator">
<div class="spinner-small"></div>
<span>Generating secure link</span>
</div>
</div>
<!-- Waiting/Authenticating phase - show codes and QR -->
<div v-else class="auth-display">
<div class="auth-content">
<div v-if="pairingCode" class="pairing-code-section">
<p class="pairing-label">Enter the code words:</p>
<div class="slot-machine stopped">
<div class="slot-reel" v-for="(word, index) in displayCode.split(' ')" :key="index">
<div class="slot-word">{{ word }}</div>
</div>
</div>
<p class="site-url">{{ siteUrlDisplay }}</p>
</div>
<div class="qr-section">
<a :href="url" @click.prevent="copyLink" class="qr-link" title="Click to copy link">
<canvas ref="qrCanvas" class="qr-code"></canvas>
</a>
</div>
</div>
<div v-if="showCopyToast" class="copy-toast">
Link copied to clipboard
</div>
<div class="waiting-indicator">
<div class="spinner-small"></div>
<span>{{ waitingMessage }}</span>
</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 { solvePoW } from '@/utils/pow'
import { words } from '@/utils/wordlist'
const props = defineProps({
active: { type: Boolean, default: false }
})
const emit = defineEmits(['authenticated', 'cancelled', 'error', 'register'])
const url = ref(null)
const pairingCode = ref(null)
const expires = ref(null)
const qrCanvas = ref(null)
const completed = ref(false)
const error = ref(null)
const phase = ref('connecting')
const settings = ref(null)
const showCopyToast = ref(false)
const animatedWords = ref(['', '', ''])
let ws = null
let copyToastTimer = null
let wordAnimationTimer = null
const displayCode = computed(() => pairingCode.value ? pairingCode.value.replace(/\./g, ' ') : '')
const siteUrlDisplay = computed(() => {
if (!settings.value) return ''
const authSiteUrl = settings.value.auth_site_url || `${location.protocol}//${location.host}/auth/`
// Remove the protocol and any trailing slash
const withoutProtocol = authSiteUrl.replace(/^https?:\/\//, '')
return withoutProtocol.endsWith('/') ? withoutProtocol.slice(0, -1) : withoutProtocol
})
const waitingMessage = computed(() => {
return phase.value === 'authenticating'
? 'Complete on another device…'
: 'Waiting for authentication…'
})
const successMessage = computed(() => 'Authenticated successfully!')
function getRandomWord() {
return words[Math.floor(Math.random() * words.length)]
}
function startWordAnimation() {
// Initialize with random words
animatedWords.value = [getRandomWord(), getRandomWord(), getRandomWord()]
let updateCount = 0
const maxUpdates = 20 // Number of cycles before stopping
// Different intervals for each slot to spin independently
const intervals = [
setInterval(() => {
const newWords = [...animatedWords.value]
newWords[0] = getRandomWord()
animatedWords.value = newWords
}, 140),
setInterval(() => {
const newWords = [...animatedWords.value]
newWords[1] = getRandomWord()
animatedWords.value = newWords
}, 170),
setInterval(() => {
const newWords = [...animatedWords.value]
newWords[2] = getRandomWord()
animatedWords.value = newWords
}, 200)
]
wordAnimationTimer = intervals
// Stop all after max updates
setTimeout(() => {
intervals.forEach(interval => clearInterval(interval))
wordAnimationTimer = null
}, maxUpdates * 170) // Average interval time
}
function stopWordAnimation() {
if (wordAnimationTimer) {
if (Array.isArray(wordAnimationTimer)) {
wordAnimationTimer.forEach(interval => clearInterval(interval))
} else {
clearInterval(wordAnimationTimer)
}
wordAnimationTimer = null
}
}
async function startRemoteAuth() {
error.value = null
completed.value = false
url.value = null
pairingCode.value = null
expires.value = null
phase.value = 'connecting'
// Start word animation
startWordAnimation()
try {
settings.value = await getSettings()
const authHost = settings.value?.auth_host
const wsPath = '/auth/ws/remote-auth/request'
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
ws = await aWebSocket(wsUrl)
// PoW challenge
const powChallenge = await ws.receive_json()
if (powChallenge.pow) {
const challenge = b64dec(powChallenge.pow.challenge)
const nonces = await solvePoW(challenge, powChallenge.pow.work)
ws.send_json({ pow: b64enc(nonces), action: 'login' })
}
// Receive the remote auth token and pairing code
const res = await ws.receive_json()
if (res.status) {
throw new Error(res.detail || `Failed to create remote auth request: ${res.status}`)
}
// Build the URL in frontend using auth_site_url from settings
const authSiteUrl = settings.value?.auth_site_url || `${location.protocol}//${location.host}/auth/`
url.value = authSiteUrl + res.token
pairingCode.value = res.pairing_code
expires.value = res.expires
// Stop word animation
stopWordAnimation()
phase.value = 'waiting'
await nextTick()
drawQR()
// Wait for authentication
while (true) {
const msg = await ws.receive_json()
if (msg.status === 'locked') {
// Someone has entered the code and is authenticating
phase.value = 'authenticating'
} else if (msg.status === 'paired') {
// Legacy/compatibility: Device paired, now authenticating
phase.value = 'authenticating'
} else if (msg.status === 'authenticated') {
// Success
completed.value = true
emit('authenticated', { session_token: msg.session_token })
break
} else if (msg.status === 'denied') {
// Explicitly denied by the authenticating device
throw new Error('Access denied')
} else if (msg.status === 'completed') {
// Registration flow
if (msg.reset_token) {
completed.value = true
emit('register', msg.reset_token)
}
break
} else if (msg.status === 'error' || msg.detail) {
throw new Error(msg.detail || 'Remote authentication failed')
}
}
} catch (err) {
console.error('Remote authentication error:', err)
const message = err.message || 'Authentication failed'
error.value = message
emit('error', message)
} finally {
if (ws) {
ws.close()
ws = null
}
}
}
function drawQR() {
if (!url.value || !qrCanvas.value) return
// Use a fixed scale that works well for most URLs
// This allows CSS to control the actual display size
const scale = 6
QRCode.toCanvas(qrCanvas.value, url.value, {
scale: scale,
margin: 0,
color: {
dark: '#000000',
light: '#FFFFFF'
}
}, err => {
if (err) console.error('QR code error:', err)
})
qrCanvas.value.removeAttribute('style')
}
async function copyLink() {
if (!url.value) return
try {
await navigator.clipboard.writeText(url.value)
showCopyToast.value = true
if (copyToastTimer) clearTimeout(copyToastTimer)
copyToastTimer = setTimeout(() => {
showCopyToast.value = false
}, 2000)
} catch (err) {
console.error('Failed to copy link:', err)
}
}
function retry() {
startRemoteAuth()
}
function cancel() {
if (ws) {
ws.close()
ws = null
}
emit('cancelled')
}
watch(() => props.active, (newVal) => {
if (newVal && !url.value && !error.value && !completed.value) {
startRemoteAuth()
}
})
onMounted(() => {
if (props.active) {
startRemoteAuth()
}
})
onUnmounted(() => {
if (ws) {
ws.close()
ws = null
}
if (copyToastTimer) {
clearTimeout(copyToastTimer)
}
stopWordAnimation()
})
defineExpose({ retry, cancel })
</script>
<style scoped>
.remote-auth-inline {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
}
.loading-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 2rem 1rem;
min-height: 180px;
justify-content: center;
}
.loading-section p {
margin: 0;
color: var(--color-text-muted);
font-size: 0.95rem;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.auth-display {
display: flex;
flex-direction: column;
gap: 1.25rem;
width: 100%;
min-height: 180px;
}
.auth-content {
display: flex;
gap: 2rem;
align-items: center;
justify-content: center;
flex-wrap: nowrap;
}
.loading-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 1rem;
}
.loading-placeholder p {
margin: 0;
color: var(--color-text-muted);
font-size: 0.95rem;
}
.pairing-code-section {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 280px;
max-width: 100%;
}
.pairing-label {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted);
font-weight: 500;
text-align: center;
}
.slot-machine {
padding: 0.875rem 1rem;
background: var(--color-surface-hover, rgba(0, 0, 0, 0.03));
border: 2px solid var(--color-border);
border-radius: var(--radius-sm, 6px);
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
display: flex;
align-items: center;
user-select: none;
pointer-events: none;
white-space: nowrap;
overflow: hidden;
}
.slot-reel {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 1;
min-width: 0;
height: 1.8em;
overflow: hidden;
position: relative;
background: var(--color-surface, rgba(255, 255, 255, 0.5));
border-radius: 3px;
}
.slot-machine:not(.stopped) .slot-reel:nth-child(1) {
animation: slotSpin 0.14s ease-in-out infinite;
}
.slot-machine:not(.stopped) .slot-reel:nth-child(2) {
animation: slotSpin 0.17s ease-in-out infinite;
}
.slot-machine:not(.stopped) .slot-reel:nth-child(3) {
animation: slotSpin 0.20s ease-in-out infinite;
}
.slot-word {
font-size: 1.25rem;
font-weight: 600;
letter-spacing: 0.05em;
text-align: center;
width: 100%;
}
.slot-machine:not(.stopped) .slot-reel:nth-child(1) .slot-word {
animation: wordRoll 0.14s ease-in-out infinite;
}
.slot-machine:not(.stopped) .slot-reel:nth-child(2) .slot-word {
animation: wordRoll 0.17s ease-in-out infinite;
}
.slot-machine:not(.stopped) .slot-reel:nth-child(3) .slot-word {
animation: wordRoll 0.20s ease-in-out infinite;
}
@keyframes slotSpin {
0% {
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
}
50% {
box-shadow: inset 0 4px 8px rgba(0, 0, 0, 0.2);
}
100% {
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
}
}
@keyframes wordRoll {
0% {
transform: translateY(-30%) scale(0.9);
opacity: 0.4;
filter: blur(1.5px);
}
25% {
transform: translateY(-10%) scale(0.95);
opacity: 0.6;
filter: blur(1px);
}
50% {
transform: translateY(0) scale(1);
opacity: 1;
filter: blur(0);
}
75% {
transform: translateY(10%) scale(0.95);
opacity: 0.6;
filter: blur(1px);
}
100% {
transform: translateY(30%) scale(0.9);
opacity: 0.4;
filter: blur(1.5px);
}
}
.site-url {
margin: 0.5rem 0 0;
font-size: 0.8rem;
color: var(--color-text-muted);
text-align: center;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
opacity: 0.8;
}
.qr-section {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
width: 180px;
}
.qr-label {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted);
font-weight: 500;
}
.qr-link {
display: block;
cursor: pointer;
transition: all 0.15s;
width: 100%;
max-width: 180px;
border-radius: var(--radius-sm, 6px);
overflow: hidden;
line-height: 0;
}
.qr-link:hover {
transform: scale(1.02);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.qr-link:active {
transform: scale(0.98);
}
.qr-code {
display: block;
width: 100%;
height: auto;
max-width: 100%;
object-fit: contain;
aspect-ratio: 1;
border-radius: var(--radius-sm, 6px);
overflow: hidden;
background: #ffffff;
}
.qr-placeholder {
position: relative;
display: flex;
align-items: center;
justify-content: center;
animation: qrPulse 2s ease-in-out infinite;
}
@keyframes qrPulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.7; }
}.waiting-indicator {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem;
background: var(--color-surface-hover, rgba(0, 0, 0, 0.02));
border-radius: var(--radius-sm, 6px);
font-size: 0.875rem;
color: var(--color-text-muted);
}
.spinner-small {
width: 16px;
height: 16px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.success-section {
padding: 1rem;
text-align: center;
min-height: 180px;
display: flex;
align-items: center;
justify-content: center;
}
.success-message {
margin: 0;
font-size: 1rem;
color: var(--color-success, #10b981);
font-weight: 500;
}
.error-section {
padding: 1rem;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
min-height: 180px;
}
.error-message {
margin: 0;
font-size: 0.95rem;
color: var(--color-error, #ef4444);
}
.copy-toast {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
padding: 0.75rem 1.5rem;
background: var(--color-success, #10b981);
color: white;
border-radius: var(--radius-sm, 6px);
font-size: 0.9rem;
font-weight: 500;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateX(-50%) translateY(1rem);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
/* Responsive adjustments */
@media (max-width: 640px) {
.auth-content {
gap: 1.5rem;
flex-direction: column;
align-items: center;
}
.pairing-code-section,
.qr-section {
width: 100%;
max-width: 280px;
}
.qr-section {
max-width: 180px;
}
}
@media (max-width: 480px) {
.pairing-code {
font-size: 1.1rem;
padding: 0.75rem 0.875rem;
}
.pairing-code-section {
width: 100%;
max-width: 100%;
}
}
</style>