Drafting autocomplete for the words. Don't check for malformed words

This commit is contained in:
Leo Vasanko
2025-12-07 00:37:40 +00:00
parent dd49907c8d
commit 9485cbd201
4 changed files with 163 additions and 39 deletions
+145 -21
View File
@@ -6,7 +6,7 @@
</div> </div>
<form @submit.prevent="submitCode" class="pairing-form"> <form @submit.prevent="submitCode" class="pairing-form">
<div class="input-group"> <div class="input-wrapper">
<input <input
ref="inputRef" ref="inputRef"
v-model="code" v-model="code"
@@ -14,11 +14,15 @@
:placeholder="placeholder" :placeholder="placeholder"
:disabled="loading || completed" :disabled="loading || completed"
autocomplete="off" autocomplete="off"
autocapitalize="characters" autocapitalize="none"
autocorrect="off"
spellcheck="false" spellcheck="false"
class="pairing-input" class="pairing-input"
@input="handleInput" @input="handleInput"
@keydown="handleKeydown"
/> />
<!-- Autocomplete hint overlay -->
<span v-if="autocompleteHint" class="autocomplete-hint">{{ autocompleteHintDisplay }}</span>
</div> </div>
<!-- Looking up state --> <!-- Looking up state -->
@@ -61,11 +65,12 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { startAuthentication } from '@simplewebauthn/browser' import { startAuthentication } from '@simplewebauthn/browser'
import aWebSocket from '@/utils/awaitable-websocket' import aWebSocket from '@/utils/awaitable-websocket'
import { getSettings } from '@/utils/settings' import { getSettings } from '@/utils/settings'
import { apiJson } from '@/utils/api' import { apiJson } from '@/utils/api'
import { getUniqueMatch, isValidWord } from '@/utils/wordlist'
const props = defineProps({ const props = defineProps({
title: { type: String, default: 'Help Another Device Sign In' }, title: { type: String, default: 'Help Another Device Sign In' },
@@ -83,15 +88,52 @@ const error = ref(null)
const completed = ref(false) const completed = ref(false)
const completedMessage = ref('') const completedMessage = ref('')
const deviceInfo = ref(null) const deviceInfo = ref(null)
const autocompleteHint = ref('')
const invalidWords = ref([]) // Track which words are invalid
let ws = null let ws = null
let lookupTimeout = null let lookupTimeout = null
// Get the current word being typed (the last word without a separator)
function getCurrentWord(input) {
const match = input.match(/[a-zA-Z]+$/)
return match ? match[0] : ''
}
// Get all words from input
function getWords(input) {
return input.trim().split(/[.\s]+/).filter(w => w.length > 0)
}
// Count completed words (separated by space or dot)
function countWords(input) {
return getWords(input).length
}
// Validate all words and return list of invalid ones
function validateWords(input) {
const words = getWords(input)
return words.filter(w => !isValidWord(w))
}
// Check if all words are valid
function allWordsValid(input) {
const words = getWords(input)
return words.length > 0 && words.every(w => isValidWord(w))
}
// Compute autocomplete hint for display
const autocompleteHintDisplay = computed(() => {
if (!autocompleteHint.value || !code.value) return ''
const currentWord = getCurrentWord(code.value)
if (!currentWord) return ''
// Return the typed text + remaining hint characters
return code.value + autocompleteHint.value.slice(currentWord.length)
})
// Check if we have exactly 3 valid words // Check if we have exactly 3 valid words
const hasThreeWords = computed(() => { const hasThreeValidWords = computed(() => {
const trimmed = code.value.trim() const words = getWords(code.value)
if (!trimmed) return false return words.length === 3 && words.every(w => isValidWord(w))
const words = trimmed.split(/[.\s]+/).filter(w => w.length > 0)
return words.length === 3
}) })
// Normalize code to dot-separated lowercase // Normalize code to dot-separated lowercase
@@ -99,29 +141,76 @@ function normalizeCode(input) {
return input.trim().toLowerCase().split(/[.\s]+/).filter(w => w).join('.') return input.trim().toLowerCase().split(/[.\s]+/).filter(w => w).join('.')
} }
// Watch for code changes and do real-time lookup // Update autocomplete hint based on current input
watch(code, () => { function updateAutocomplete() {
// Clear previous timeout const currentWord = getCurrentWord(code.value)
const wordCount = countWords(code.value)
// Don't autocomplete if we already have 3 words or no current word
if (wordCount >= 3 || !currentWord || currentWord.length < 1) {
autocompleteHint.value = ''
return
}
// Find unique match for current prefix
const match = getUniqueMatch(currentWord.toLowerCase())
if (match && match !== currentWord.toLowerCase()) {
autocompleteHint.value = match
} else {
autocompleteHint.value = ''
}
}
// Apply autocomplete - complete the current word
function applyAutocomplete() {
if (!autocompleteHint.value) return false
const currentWord = getCurrentWord(code.value)
if (!currentWord) return false
// Replace the current partial word with the full word + space
const beforeCurrent = code.value.slice(0, code.value.length - currentWord.length)
code.value = beforeCurrent + autocompleteHint.value + ' '
autocompleteHint.value = ''
return true
}
function handleInput() {
// Update autocomplete on input
updateAutocomplete()
// Clear previous lookup timeout
if (lookupTimeout) { if (lookupTimeout) {
clearTimeout(lookupTimeout) clearTimeout(lookupTimeout)
lookupTimeout = null lookupTimeout = null
} }
// Reset device info and error when code changes // Reset device info when code changes
deviceInfo.value = null deviceInfo.value = null
error.value = null error.value = null
invalidWords.value = []
// Only lookup when we have 3 words const words = getWords(code.value)
if (hasThreeWords.value) {
// Debounce the lookup slightly to avoid too many requests // Check for invalid words when we have 3 words
if (words.length === 3) {
const invalid = validateWords(code.value)
if (invalid.length > 0) {
invalidWords.value = invalid
error.value = `Unknown word${invalid.length > 1 ? 's' : ''}: ${invalid.join(', ')}`
return
}
// All words valid, do lookup
lookupTimeout = setTimeout(() => { lookupTimeout = setTimeout(() => {
lookupDeviceInfo() lookupDeviceInfo()
}, 150) }, 150)
} }
}) }
async function lookupDeviceInfo() { async function lookupDeviceInfo() {
if (!hasThreeWords.value || loading.value) return if (!hasThreeValidWords.value || loading.value) return
lookingUp.value = true lookingUp.value = true
error.value = null error.value = null
@@ -144,8 +233,14 @@ async function lookupDeviceInfo() {
} }
} }
function handleInput() { function handleKeydown(event) {
// Error is cleared by the watcher // Tab or Space triggers autocomplete if we have a hint
if ((event.key === 'Tab' || event.key === ' ') && autocompleteHint.value) {
const applied = applyAutocomplete()
if (applied) {
event.preventDefault()
}
}
} }
async function submitCode() { async function submitCode() {
@@ -209,12 +304,27 @@ function reset() {
completedMessage.value = '' completedMessage.value = ''
deviceInfo.value = null deviceInfo.value = null
lookingUp.value = false lookingUp.value = false
autocompleteHint.value = ''
invalidWords.value = []
} }
onMounted(() => { onMounted(() => {
inputRef.value?.focus() 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 }) defineExpose({ reset })
</script> </script>
@@ -243,15 +353,16 @@ defineExpose({ reset })
gap: 0.5rem; gap: 0.5rem;
} }
.input-group { .input-wrapper {
position: relative;
display: flex; display: flex;
gap: 0.5rem;
} }
.pairing-input { .pairing-input {
flex: 1; flex: 1;
padding: 0.625rem 0.75rem; padding: 0.625rem 0.75rem;
font-size: 1rem; font-size: 1rem;
font-family: inherit;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 4px); border-radius: var(--radius-sm, 4px);
background: var(--color-surface); background: var(--color-surface);
@@ -274,6 +385,19 @@ defineExpose({ reset })
opacity: 0.6; opacity: 0.6;
} }
.autocomplete-hint {
position: absolute;
left: 0;
top: 0;
padding: 0.625rem 0.75rem;
font-size: 1rem;
font-family: inherit;
color: var(--color-text-muted);
opacity: 0.5;
pointer-events: none;
white-space: pre;
}
.lookup-status { .lookup-status {
display: flex; display: flex;
align-items: center; align-items: center;