Drafting remote auth linking.

This commit is contained in:
Leo Vasanko
2025-12-06 16:43:47 +00:00
parent 2b7481cd58
commit 4da1127976
9 changed files with 1554 additions and 4 deletions
@@ -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>