914 lines
25 KiB
Vue
914 lines
25 KiB
Vue
<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-row">
|
|
<div class="input-wrapper" :class="{ 'has-error': serverError, 'is-complete': deviceInfo && !serverError && !completed, 'is-verified': completed }">
|
|
<!-- Colored overlay to show invalid words in red -->
|
|
<span v-if="hasInvalidWord" class="input-overlay" aria-hidden="true"><span v-for="(segment, i) in coloredSegments" :key="i" :class="{ 'invalid-word': segment.invalid }">{{ segment.text }}</span></span>
|
|
<input
|
|
ref="inputRef"
|
|
v-model="code"
|
|
type="text"
|
|
:placeholder="placeholder"
|
|
:disabled="completed"
|
|
autocomplete="off"
|
|
autocapitalize="none"
|
|
autocorrect="off"
|
|
spellcheck="false"
|
|
class="pairing-input"
|
|
:class="{ 'text-hidden': hasInvalidWord }"
|
|
@input="handleInput"
|
|
@keydown="handleKeydown"
|
|
@click="updateAutocomplete"
|
|
@select="updateAutocomplete"
|
|
/>
|
|
<!-- Autocomplete hint overlay: shows text before cursor + hint suffix -->
|
|
<span v-if="autocompleteHint && !hasInvalidWord" class="autocomplete-hint" aria-hidden="true"><span class="hint-prefix">{{ autocompletePrefix }}</span><span class="hint-suffix">{{ autocompleteSuffix }}</span></span>
|
|
</div>
|
|
<!-- Processing status beside input -->
|
|
<div v-if="processingStatus" class="processing-status">
|
|
<span class="processing-icon">{{ processingStatus === 'pow' ? '🔐' : '📡' }}</span>
|
|
<span class="processing-spinner-small"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Device info display (shown when 3 words match a request) -->
|
|
<div v-if="deviceInfo && !error && !completed" class="device-info">
|
|
<p class="device-info-label">📱 Device requesting login:</p>
|
|
<div class="device-info-details">
|
|
<div class="device-detail">
|
|
<span class="detail-label">Site:</span>
|
|
<span class="detail-value">{{ deviceInfo.host }}</span>
|
|
</div>
|
|
<div class="device-detail">
|
|
<span class="detail-label">Browser:</span>
|
|
<span class="detail-value">{{ deviceInfo.user_agent_pretty }}</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
ref="submitBtnRef"
|
|
type="submit"
|
|
:disabled="loading"
|
|
class="btn-primary"
|
|
style="margin-top: 0.75rem; width: 100%;"
|
|
>
|
|
{{ loading ? 'Authenticating…' : 'Authenticate to Log In Device' }}
|
|
</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, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
|
import { startAuthentication } from '@simplewebauthn/browser'
|
|
import aWebSocket from '@/utils/awaitable-websocket'
|
|
import { dec as b64dec, enc as b64enc } from '@/utils/base64url'
|
|
import { getSettings } from '@/utils/settings'
|
|
import { getUniqueMatch, isValidWord, isValidPrefix } from '@/utils/wordlist'
|
|
import { solvePoW } from '@/utils/pow'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
|
|
const authStore = useAuthStore()
|
|
|
|
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 three words' }
|
|
})
|
|
|
|
const emit = defineEmits(['completed', 'error', 'cancelled'])
|
|
|
|
const inputRef = ref(null)
|
|
const submitBtnRef = ref(null)
|
|
const code = ref('')
|
|
const loading = ref(false)
|
|
const isProcessing = ref(false) // Track if we're processing (solving PoW or waiting for server)
|
|
const processingStatus = ref('') // 'pow' = computing PoW, 'server' = waiting for server
|
|
const error = ref(null)
|
|
const completed = ref(false)
|
|
const completedMessage = ref('')
|
|
const deviceInfo = ref(null)
|
|
const autocompleteHint = ref('')
|
|
const hasInvalidWord = ref(false) // Track if any word is invalid (not a valid prefix)
|
|
const serverError = ref(false) // Track if server rejected the code
|
|
const cursorPos = ref(0) // Track cursor position for autocomplete
|
|
|
|
// Unified WebSocket state
|
|
let ws = null
|
|
let wsConnecting = false
|
|
let currentChallenge = null // Current PoW challenge from server
|
|
let currentWork = null
|
|
let powPromise = null // Promise for background PoW computation
|
|
let powSolution = null // Solved PoW solution ready to use
|
|
let lookupTimeout = null
|
|
let lastLookedUpCode = null // Track last code we looked up to avoid duplicates
|
|
|
|
// Get the word at cursor position
|
|
function getWordAtCursor(input, cursor) {
|
|
if (!input || cursor < 0) return { word: '', start: 0, end: 0 }
|
|
|
|
// Find word boundaries around cursor
|
|
let start = cursor
|
|
let end = cursor
|
|
|
|
// Move start back to find word beginning
|
|
while (start > 0 && /[a-zA-Z]/.test(input[start - 1])) {
|
|
start--
|
|
}
|
|
|
|
// Move end forward to find word end
|
|
while (end < input.length && /[a-zA-Z]/.test(input[end])) {
|
|
end++
|
|
}
|
|
|
|
return {
|
|
word: input.slice(start, end),
|
|
start,
|
|
end
|
|
}
|
|
}
|
|
|
|
// Get the current word being typed (the last word without a separator) - kept for validation
|
|
function getCurrentWord(input) {
|
|
const match = input.match(/[a-zA-Z]+$/)
|
|
return match ? match[0] : ''
|
|
}
|
|
|
|
// Get all words from input (normalizes whitespace)
|
|
function getWords(input) {
|
|
return input.trim().split(/[.\s]+/).filter(w => w.length > 0)
|
|
}
|
|
|
|
// Count complete words (words followed by a separator)
|
|
function countCompleteWords(input) {
|
|
// Count words that are followed by a separator
|
|
const endsWithSeparator = /[.\s]$/.test(input)
|
|
const words = getWords(input)
|
|
return endsWithSeparator ? words.length : Math.max(0, words.length - 1)
|
|
}
|
|
|
|
// Check validity and return info about each word segment
|
|
function analyzeWords(input) {
|
|
if (!input) return { valid: true, segments: [] }
|
|
|
|
const segments = []
|
|
const endsWithSeparator = /[.\s]$/.test(input)
|
|
|
|
// Parse input preserving separators
|
|
let pos = 0
|
|
const regex = /([a-zA-Z]+)|([.\s]+)/g
|
|
let match
|
|
|
|
while ((match = regex.exec(input)) !== null) {
|
|
if (match[1]) {
|
|
// Word
|
|
segments.push({ text: match[1], isWord: true, start: match.index })
|
|
} else if (match[2]) {
|
|
// Separator
|
|
segments.push({ text: match[2], isWord: false, start: match.index })
|
|
}
|
|
}
|
|
|
|
// Determine validity of each word
|
|
const words = segments.filter(s => s.isWord)
|
|
let allValid = true
|
|
|
|
words.forEach((wordSeg, idx) => {
|
|
const isLastWord = idx === words.length - 1
|
|
const word = wordSeg.text.toLowerCase()
|
|
|
|
if (isLastWord && !endsWithSeparator) {
|
|
// Currently being typed - just needs to be valid prefix
|
|
wordSeg.invalid = !isValidPrefix(word)
|
|
} else {
|
|
// Complete word - must be a valid word
|
|
wordSeg.invalid = !isValidWord(word)
|
|
}
|
|
|
|
if (wordSeg.invalid) allValid = false
|
|
})
|
|
|
|
return { valid: allValid, segments }
|
|
}
|
|
|
|
// Computed property for colored segments
|
|
const coloredSegments = computed(() => {
|
|
const { segments } = analyzeWords(code.value)
|
|
return segments.map(s => ({
|
|
text: s.text,
|
|
invalid: s.invalid || false
|
|
}))
|
|
})
|
|
|
|
// Check if all completed words are valid and current word is a valid prefix
|
|
function checkWordsValidity(input) {
|
|
return analyzeWords(input).valid
|
|
}
|
|
|
|
// Check if all words are valid complete words
|
|
function allWordsValid(input) {
|
|
const words = getWords(input)
|
|
return words.length > 0 && words.every(w => isValidWord(w))
|
|
}
|
|
|
|
// Compute autocomplete prefix (text up to and including current word at cursor)
|
|
const autocompletePrefix = computed(() => {
|
|
if (!autocompleteHint.value || !code.value) return ''
|
|
const { word, end } = getWordAtCursor(code.value, cursorPos.value)
|
|
if (!word) return ''
|
|
// Return text up to end of current word (this part is transparent)
|
|
return code.value.slice(0, end)
|
|
})
|
|
|
|
// Compute autocomplete suffix (the completion part only)
|
|
const autocompleteSuffix = computed(() => {
|
|
if (!autocompleteHint.value || !code.value) return ''
|
|
const { word } = getWordAtCursor(code.value, cursorPos.value)
|
|
if (!word) return ''
|
|
// Return only the remaining characters of the hint
|
|
return autocompleteHint.value.slice(word.length)
|
|
})
|
|
|
|
// Check if we have exactly 3 valid words
|
|
const hasThreeValidWords = computed(() => {
|
|
const words = getWords(code.value)
|
|
return words.length === 3 && words.every(w => isValidWord(w))
|
|
})
|
|
|
|
// Normalize code to dot-separated lowercase
|
|
function normalizeCode(input) {
|
|
return input.trim().toLowerCase().split(/[.\s]+/).filter(w => w).join('.')
|
|
}
|
|
|
|
// Start solving PoW in background
|
|
function startPowSolving() {
|
|
if (!currentChallenge || powPromise) return
|
|
|
|
const challenge = b64dec(currentChallenge)
|
|
powPromise = solvePoW(challenge, currentWork).then(solution => {
|
|
powSolution = solution
|
|
powPromise = null
|
|
})
|
|
}
|
|
|
|
// Get the solved PoW solution, waiting if necessary
|
|
async function getPowSolution() {
|
|
if (powSolution) {
|
|
const solution = powSolution
|
|
powSolution = null
|
|
return solution
|
|
}
|
|
if (powPromise) {
|
|
await powPromise
|
|
const solution = powSolution
|
|
powSolution = null
|
|
return solution
|
|
}
|
|
// Need to solve now
|
|
if (!currentChallenge) {
|
|
throw new Error('No PoW challenge available')
|
|
}
|
|
const challenge = b64dec(currentChallenge)
|
|
return await solvePoW(challenge, currentWork)
|
|
}
|
|
|
|
// Update challenge from server response and start solving
|
|
function updateChallenge(pow) {
|
|
if (pow?.challenge) {
|
|
currentChallenge = pow.challenge
|
|
currentWork = pow.work
|
|
powSolution = null
|
|
powPromise = null
|
|
// Start solving in background immediately
|
|
startPowSolving()
|
|
}
|
|
}
|
|
|
|
// Connect to the unified WebSocket endpoint
|
|
async function ensureConnection() {
|
|
if (ws || wsConnecting) return
|
|
|
|
wsConnecting = true
|
|
try {
|
|
const settings = await getSettings()
|
|
const authHost = settings?.auth_host
|
|
|
|
// Single unified endpoint - no code in URL!
|
|
const wsPath = '/auth/ws/remote-auth/pair'
|
|
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
|
|
|
|
ws = await aWebSocket(wsUrl)
|
|
|
|
// First message is PoW challenge
|
|
const msg = await ws.receive_json()
|
|
if (msg.status && msg.detail) {
|
|
throw new Error(msg.detail)
|
|
}
|
|
if (!msg.pow?.challenge) {
|
|
throw new Error('Server did not send PoW challenge')
|
|
}
|
|
updateChallenge(msg.pow)
|
|
} catch (err) {
|
|
console.error('WebSocket connection error:', err)
|
|
authStore.showMessage('Pairing service: ' + (err.message || 'Connection failed'), 'error', 4000)
|
|
ws = null
|
|
throw err // Re-throw so caller knows connection failed
|
|
} finally {
|
|
wsConnecting = false
|
|
}
|
|
}
|
|
|
|
// Update autocomplete hint based on current input and cursor position
|
|
function updateAutocomplete() {
|
|
// Update cursor position
|
|
cursorPos.value = inputRef.value?.selectionStart ?? code.value.length
|
|
|
|
const { word, end } = getWordAtCursor(code.value, cursorPos.value)
|
|
const completeWordCount = countCompleteWords(code.value)
|
|
|
|
// Don't autocomplete if:
|
|
// - we already have 3 complete words
|
|
// - no current word at cursor
|
|
// - cursor is not at the end of the word (user is editing middle of word)
|
|
if (completeWordCount >= 3 || !word || word.length < 1 || cursorPos.value !== end) {
|
|
autocompleteHint.value = ''
|
|
return
|
|
}
|
|
|
|
// Find unique match for current prefix
|
|
const match = getUniqueMatch(word.toLowerCase())
|
|
if (match && match !== word.toLowerCase()) {
|
|
autocompleteHint.value = match
|
|
} else {
|
|
autocompleteHint.value = ''
|
|
}
|
|
}
|
|
|
|
// Apply autocomplete - complete the current word at cursor
|
|
function applyAutocomplete() {
|
|
if (!autocompleteHint.value) return false
|
|
|
|
const { word, start, end } = getWordAtCursor(code.value, cursorPos.value)
|
|
if (!word) return false
|
|
|
|
// Count how many complete words we have before this one
|
|
const before = code.value.slice(0, start)
|
|
const wordsBefore = getWords(before).length
|
|
|
|
// Don't add space after the 3rd word (wordsBefore is 0-indexed count of words before current)
|
|
const isThirdWord = wordsBefore === 2
|
|
const suffix = isThirdWord ? '' : ' '
|
|
|
|
// Replace the current word with the full word (+ space if not 3rd word)
|
|
const after = code.value.slice(end)
|
|
code.value = before + autocompleteHint.value + suffix + after.trimStart()
|
|
|
|
// Move cursor to after the inserted word
|
|
const newPos = start + autocompleteHint.value.length + suffix.length
|
|
nextTick(() => {
|
|
inputRef.value?.setSelectionRange(newPos, newPos)
|
|
cursorPos.value = newPos
|
|
})
|
|
|
|
autocompleteHint.value = ''
|
|
return true
|
|
}
|
|
|
|
function handleInput() {
|
|
// Normalize: trim trailing whitespace if we have 3 complete words
|
|
const words = getWords(code.value)
|
|
if (words.length >= 3) {
|
|
// Normalize to exactly 3 words with single spaces, no trailing space
|
|
const normalized = words.slice(0, 3).join(' ')
|
|
if (code.value !== normalized) {
|
|
const cursorWasAtEnd = cursorPos.value >= code.value.length
|
|
code.value = normalized
|
|
// Keep cursor at end if it was there
|
|
if (cursorWasAtEnd) {
|
|
nextTick(() => {
|
|
inputRef.value?.setSelectionRange(normalized.length, normalized.length)
|
|
cursorPos.value = normalized.length
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update autocomplete on input
|
|
updateAutocomplete()
|
|
|
|
// Clear previous lookup timeout
|
|
if (lookupTimeout) {
|
|
clearTimeout(lookupTimeout)
|
|
lookupTimeout = null
|
|
}
|
|
|
|
// Reset device info when code changes
|
|
deviceInfo.value = null
|
|
error.value = null
|
|
serverError.value = false
|
|
|
|
// Check if any word is invalid (not a valid prefix or complete word)
|
|
hasInvalidWord.value = !checkWordsValidity(code.value)
|
|
|
|
// Re-get words after normalization
|
|
const currentWords = getWords(code.value)
|
|
|
|
// Connect to WebSocket on first meaningful input (start solving PoW early)
|
|
if (currentWords.length >= 1 && !ws && !wsConnecting) {
|
|
ensureConnection()
|
|
}
|
|
|
|
// Check for invalid words when we have 3 words
|
|
if (currentWords.length === 3) {
|
|
if (!allWordsValid(code.value)) {
|
|
// Don't show error message - the red input is enough feedback
|
|
return
|
|
}
|
|
|
|
// All words valid, do lookup
|
|
lookupTimeout = setTimeout(() => {
|
|
lookupDeviceInfo()
|
|
}, 150)
|
|
}
|
|
}
|
|
|
|
async function lookupDeviceInfo() {
|
|
// Don't start a new lookup if we're already processing or loading
|
|
if (isProcessing.value || loading.value) return
|
|
|
|
// Check if current input has 3 valid words
|
|
if (!hasThreeValidWords.value) return
|
|
|
|
const normalizedCode = normalizeCode(code.value)
|
|
|
|
// Skip if we already successfully looked up this exact code
|
|
if (normalizedCode === lastLookedUpCode && deviceInfo.value) {
|
|
return
|
|
}
|
|
|
|
isProcessing.value = true
|
|
processingStatus.value = 'pow'
|
|
error.value = null
|
|
serverError.value = false
|
|
|
|
try {
|
|
// Ensure we have a connection
|
|
await ensureConnection()
|
|
|
|
if (!ws) {
|
|
throw new Error('Failed to connect')
|
|
}
|
|
|
|
// Get PoW solution (may wait for background computation)
|
|
const solution = await getPowSolution()
|
|
const powB64 = b64enc(solution)
|
|
|
|
// Take the field value NOW (after PoW is solved) - user may have changed it
|
|
const currentCode = normalizeCode(code.value)
|
|
|
|
// Check if we still have 3 valid words
|
|
if (!hasThreeValidWords.value) {
|
|
// User changed input while we were solving PoW - abort this lookup
|
|
return
|
|
}
|
|
|
|
// Now waiting for server
|
|
processingStatus.value = 'server'
|
|
|
|
// Send current code + PoW
|
|
ws.send_json({
|
|
code: currentCode,
|
|
pow: powB64
|
|
})
|
|
|
|
// Receive response
|
|
const res = await ws.receive_json()
|
|
|
|
// Update challenge for next request (included in all responses)
|
|
updateChallenge(res.pow)
|
|
|
|
// Check for error response (HTTP status code format)
|
|
if (typeof res.status === 'number' && res.status >= 400) {
|
|
authStore.showMessage(res.detail || 'Request failed', 'error', 4000)
|
|
serverError.value = true
|
|
deviceInfo.value = null
|
|
lastLookedUpCode = null
|
|
// Keep connection open for retries (challenge was updated above)
|
|
return
|
|
}
|
|
|
|
if (res.status === 'found' && res.host) {
|
|
// Success! Set field to normalized value
|
|
code.value = currentCode.replace(/\./g, ' ')
|
|
deviceInfo.value = {
|
|
host: res.host,
|
|
user_agent_pretty: res.user_agent_pretty
|
|
}
|
|
lastLookedUpCode = currentCode
|
|
// Focus the submit button after device info loads
|
|
nextTick(() => {
|
|
submitBtnRef.value?.focus()
|
|
})
|
|
} else {
|
|
authStore.showMessage('Unexpected response from server', 'error', 3000)
|
|
serverError.value = true
|
|
deviceInfo.value = null
|
|
lastLookedUpCode = null
|
|
}
|
|
} catch (err) {
|
|
console.error('Lookup error:', err)
|
|
// Show appropriate message for connection errors
|
|
const isDisconnect = err.message?.includes('closed')
|
|
authStore.showMessage(
|
|
isDisconnect ? 'Connection lost. Please try again.' : (err.message || 'Lookup failed'),
|
|
'error',
|
|
4000
|
|
)
|
|
serverError.value = true
|
|
deviceInfo.value = null
|
|
lastLookedUpCode = null
|
|
// Close broken connection
|
|
if (ws) {
|
|
ws.close()
|
|
ws = null
|
|
}
|
|
} finally {
|
|
isProcessing.value = false
|
|
processingStatus.value = ''
|
|
}
|
|
}
|
|
|
|
function handleKeydown(event) {
|
|
// Tab triggers autocomplete if we have a hint
|
|
if (event.key === 'Tab') {
|
|
if (autocompleteHint.value) {
|
|
const applied = applyAutocomplete()
|
|
if (applied) {
|
|
event.preventDefault()
|
|
// Trigger input handling since programmatic changes don't fire input event
|
|
handleInput()
|
|
return
|
|
}
|
|
}
|
|
// Prevent Tab from moving focus if input has content (keep user in the input)
|
|
if (code.value.trim()) {
|
|
event.preventDefault()
|
|
}
|
|
return
|
|
}
|
|
|
|
// Space triggers autocomplete if we have a hint
|
|
if (event.key === ' ' && autocompleteHint.value) {
|
|
const applied = applyAutocomplete()
|
|
if (applied) {
|
|
event.preventDefault()
|
|
// Trigger input handling since programmatic changes don't fire input event
|
|
handleInput()
|
|
}
|
|
}
|
|
}
|
|
|
|
async function submitCode() {
|
|
if (!deviceInfo.value || loading.value) return
|
|
|
|
loading.value = true
|
|
error.value = null
|
|
|
|
try {
|
|
// Ensure we still have the connection
|
|
if (!ws) {
|
|
await ensureConnection()
|
|
}
|
|
|
|
if (!ws) {
|
|
throw new Error('Failed to connect')
|
|
}
|
|
|
|
// Get PoW solution for authenticate request
|
|
const solution = await getPowSolution()
|
|
const powB64 = b64enc(solution)
|
|
|
|
// Send authenticate request with PoW
|
|
ws.send_json({
|
|
authenticate: true,
|
|
pow: powB64
|
|
})
|
|
|
|
// Receive authentication options
|
|
const res = await ws.receive_json()
|
|
|
|
// Check for server error response
|
|
if (typeof res.status === 'number' && res.status >= 400) {
|
|
throw new Error(res.detail || 'Authentication failed')
|
|
}
|
|
|
|
if (!res.optionsJSON) {
|
|
throw new Error(res.detail || 'Failed to get authentication options')
|
|
}
|
|
|
|
// Perform WebAuthn authentication
|
|
const authResponse = await startAuthentication(res.optionsJSON)
|
|
ws.send_json(authResponse)
|
|
|
|
// Wait for confirmation
|
|
const result = await ws.receive_json()
|
|
|
|
// Check for server error response
|
|
if (typeof result.status === 'number' && result.status >= 400) {
|
|
throw new Error(result.detail || 'Authentication failed')
|
|
}
|
|
|
|
if (result.status === 'success') {
|
|
completed.value = true
|
|
completedMessage.value = result.message || 'The other device is now logged in.'
|
|
authStore.showMessage('Device authenticated successfully!', 'success', 3000)
|
|
emit('completed')
|
|
} else {
|
|
throw new Error(result.detail || 'Authentication failed')
|
|
}
|
|
} catch (err) {
|
|
console.error('Pairing error:', err)
|
|
const isDisconnect = err.message?.includes('closed')
|
|
const message = err.name === 'NotAllowedError'
|
|
? 'Passkey authentication was cancelled'
|
|
: isDisconnect
|
|
? 'Connection lost. Please try again.'
|
|
: (err.message || 'Failed to connect')
|
|
error.value = message
|
|
authStore.showMessage(message, 'error', 4000)
|
|
emit('error', message)
|
|
} finally {
|
|
loading.value = false
|
|
// Close the WebSocket after authentication attempt
|
|
if (ws) {
|
|
ws.close()
|
|
ws = null
|
|
}
|
|
}
|
|
}
|
|
|
|
function reset() {
|
|
code.value = ''
|
|
error.value = null
|
|
serverError.value = false
|
|
completed.value = false
|
|
completedMessage.value = ''
|
|
deviceInfo.value = null
|
|
isProcessing.value = false
|
|
processingStatus.value = ''
|
|
autocompleteHint.value = ''
|
|
hasInvalidWord.value = false
|
|
lastLookedUpCode = null
|
|
// Close WebSocket on reset
|
|
if (ws) {
|
|
ws.close()
|
|
ws = null
|
|
}
|
|
currentChallenge = null
|
|
currentWork = null
|
|
powPromise = null
|
|
powSolution = null
|
|
}
|
|
|
|
onMounted(() => {
|
|
inputRef.value?.focus()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
// Clean up timeout on unmount
|
|
if (lookupTimeout) {
|
|
clearTimeout(lookupTimeout)
|
|
lookupTimeout = null
|
|
}
|
|
// Clean up WebSocket on unmount
|
|
if (ws) {
|
|
ws.close()
|
|
ws = null
|
|
}
|
|
})
|
|
|
|
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-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.input-wrapper {
|
|
position: relative;
|
|
display: flex;
|
|
max-width: 21ch;
|
|
}
|
|
|
|
.input-wrapper.has-error .pairing-input {
|
|
border-color: var(--color-error, #ef4444);
|
|
}
|
|
|
|
.input-wrapper.has-error .pairing-input:focus {
|
|
border-color: var(--color-error, #ef4444);
|
|
box-shadow: 0 0 0 2px var(--color-error-alpha, rgba(239, 68, 68, 0.2));
|
|
}
|
|
|
|
.input-wrapper.is-complete .pairing-input {
|
|
border-color: var(--color-success, #10b981);
|
|
}
|
|
|
|
.input-wrapper.is-complete .pairing-input:focus {
|
|
border-color: var(--color-success, #10b981);
|
|
box-shadow: 0 0 0 2px var(--color-success-alpha, rgba(16, 185, 129, 0.2));
|
|
}
|
|
|
|
.input-wrapper.is-verified .pairing-input {
|
|
border-color: var(--color-success, #10b981);
|
|
background: var(--color-success-bg, rgba(16, 185, 129, 0.1));
|
|
}
|
|
|
|
.input-overlay {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
padding: 0.625rem 0.75rem;
|
|
font-size: 1rem;
|
|
font-family: inherit;
|
|
color: var(--color-text);
|
|
pointer-events: none;
|
|
white-space: pre;
|
|
border: 1px solid transparent;
|
|
}
|
|
|
|
.input-overlay .invalid-word {
|
|
color: var(--color-error, #ef4444);
|
|
}
|
|
|
|
.pairing-input {
|
|
flex: 1;
|
|
padding: 0.625rem 0.75rem;
|
|
font-size: 1rem;
|
|
font-family: inherit;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm, 4px);
|
|
background: var(--color-surface);
|
|
color: var(--color-text);
|
|
caret-color: var(--color-text);
|
|
}
|
|
|
|
.pairing-input.text-hidden {
|
|
color: transparent;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.autocomplete-hint {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
padding: 0.625rem 0.75rem;
|
|
font-size: 1rem;
|
|
font-family: inherit;
|
|
pointer-events: none;
|
|
white-space: pre;
|
|
}
|
|
|
|
.autocomplete-hint .hint-prefix {
|
|
color: transparent;
|
|
}
|
|
|
|
.autocomplete-hint .hint-suffix {
|
|
color: var(--color-text-muted);
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.processing-status {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
font-size: 0.875rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.processing-icon {
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.processing-spinner-small {
|
|
width: 12px;
|
|
height: 12px;
|
|
border: 2px 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); }
|
|
}
|
|
|
|
.device-info {
|
|
padding: 0.875rem;
|
|
background: var(--color-surface-hover, rgba(0, 0, 0, 0.03));
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm, 4px);
|
|
}
|
|
|
|
.device-info-label {
|
|
margin: 0 0 0.5rem;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
color: var(--color-text);
|
|
}
|
|
|
|
.device-info-details {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.375rem;
|
|
}
|
|
|
|
.device-detail {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.detail-label {
|
|
color: var(--color-text-muted);
|
|
min-width: 4rem;
|
|
}
|
|
|
|
.detail-value {
|
|
color: var(--color-text);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.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>
|