Drafting autocomplete for the words. Don't check for malformed words
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user