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
+151 -27
View File
@@ -6,7 +6,7 @@
</div>
<form @submit.prevent="submitCode" class="pairing-form">
<div class="input-group">
<div class="input-wrapper">
<input
ref="inputRef"
v-model="code"
@@ -14,13 +14,17 @@
:placeholder="placeholder"
:disabled="loading || completed"
autocomplete="off"
autocapitalize="characters"
autocapitalize="none"
autocorrect="off"
spellcheck="false"
class="pairing-input"
@input="handleInput"
@keydown="handleKeydown"
/>
<!-- Autocomplete hint overlay -->
<span v-if="autocompleteHint" class="autocomplete-hint">{{ autocompleteHintDisplay }}</span>
</div>
<!-- Looking up state -->
<div v-if="lookingUp" class="lookup-status">
<span class="lookup-spinner"></span>
@@ -49,7 +53,7 @@
{{ loading ? 'Authenticating…' : 'Authenticate to Log In Device' }}
</button>
</div>
<p v-if="error" class="error-message">{{ error }}</p>
</form>
@@ -61,11 +65,12 @@
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { startAuthentication } from '@simplewebauthn/browser'
import aWebSocket from '@/utils/awaitable-websocket'
import { getSettings } from '@/utils/settings'
import { apiJson } from '@/utils/api'
import { getUniqueMatch, isValidWord } from '@/utils/wordlist'
const props = defineProps({
title: { type: String, default: 'Help Another Device Sign In' },
@@ -83,15 +88,52 @@ const error = ref(null)
const completed = ref(false)
const completedMessage = ref('')
const deviceInfo = ref(null)
const autocompleteHint = ref('')
const invalidWords = ref([]) // Track which words are invalid
let ws = 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
const hasThreeWords = computed(() => {
const trimmed = code.value.trim()
if (!trimmed) return false
const words = trimmed.split(/[.\s]+/).filter(w => w.length > 0)
return words.length === 3
const hasThreeValidWords = computed(() => {
const words = getWords(code.value)
return words.length === 3 && words.every(w => isValidWord(w))
})
// Normalize code to dot-separated lowercase
@@ -99,33 +141,80 @@ function normalizeCode(input) {
return input.trim().toLowerCase().split(/[.\s]+/).filter(w => w).join('.')
}
// Watch for code changes and do real-time lookup
watch(code, () => {
// Clear previous timeout
// Update autocomplete hint based on current input
function updateAutocomplete() {
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) {
clearTimeout(lookupTimeout)
lookupTimeout = null
}
// Reset device info and error when code changes
// Reset device info when code changes
deviceInfo.value = null
error.value = null
// Only lookup when we have 3 words
if (hasThreeWords.value) {
// Debounce the lookup slightly to avoid too many requests
invalidWords.value = []
const words = getWords(code.value)
// 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(() => {
lookupDeviceInfo()
}, 150)
}
})
}
async function lookupDeviceInfo() {
if (!hasThreeWords.value || loading.value) return
if (!hasThreeValidWords.value || loading.value) return
lookingUp.value = true
error.value = null
try {
const normalizedCode = normalizeCode(code.value)
const info = await apiJson(`/auth/api/remote-auth-info?code=${encodeURIComponent(normalizedCode)}`)
@@ -144,8 +233,14 @@ async function lookupDeviceInfo() {
}
}
function handleInput() {
// Error is cleared by the watcher
function handleKeydown(event) {
// 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() {
@@ -209,12 +304,27 @@ function reset() {
completedMessage.value = ''
deviceInfo.value = null
lookingUp.value = false
autocompleteHint.value = ''
invalidWords.value = []
}
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>
@@ -243,15 +353,16 @@ defineExpose({ reset })
gap: 0.5rem;
}
.input-group {
.input-wrapper {
position: relative;
display: flex;
gap: 0.5rem;
}
.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);
@@ -274,6 +385,19 @@ defineExpose({ reset })
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 {
display: flex;
align-items: center;
+7 -7
View File
@@ -209,10 +209,10 @@ async def get_settings():
@app.get("/token-info")
async def api_token_info(token: str):
"""Get information about a token (remote auth or reset token).
This endpoint allows the frontend to determine what type of token it is
dealing with and get relevant information for display.
Returns:
- type: "remote_auth" or "reset"
- For remote_auth: host, user_agent_pretty (requesting device info)
@@ -220,7 +220,7 @@ async def api_token_info(token: str):
"""
if not passphrase.is_well_formed(token):
raise HTTPException(status_code=404, detail="Invalid token")
# Check if this is a remote auth token
if remoteauth.instance is not None:
request = await remoteauth.instance.get_request(token)
@@ -232,7 +232,7 @@ async def api_token_info(token: str):
"user_agent_pretty": useragent.compact_user_agent(request.user_agent),
"ip": request.ip,
}
# Check if this is a reset token
try:
reset_token = await get_reset(token)
@@ -249,17 +249,17 @@ async def api_token_info(token: str):
@app.get("/remote-auth-info")
async def api_remote_auth_info(code: str):
"""Get information about a remote auth request by pairing code (first 3 words).
This is used for real-time lookup as the user types the pairing code.
Returns info about the requesting device without initiating authentication.
"""
if remoteauth.instance is None:
raise HTTPException(status_code=404, detail="Remote auth not available")
request = await remoteauth.instance.get_request_by_pairing_code(code)
if request is None:
raise HTTPException(status_code=404, detail="Invalid or expired code")
return {
"host": request.host,
"user_agent": request.user_agent,
+4 -4
View File
@@ -127,21 +127,21 @@ async def examples_page():
@app.get("/auth/{token}")
async def token_link(token: str):
"""Serve the appropriate app based on token type.
This endpoint handles both:
- Remote auth tokens (cross-device login): serve restricted app
- Reset tokens (password reset / device addition): serve reset app
The frontend will detect the type by calling /auth/api/token-info.
"""
if not passphrase.is_well_formed(token):
raise HTTPException(status_code=404)
# Check if this is a remote auth token first (they're in-memory, fast lookup)
if remoteauth.instance is not None:
request = await remoteauth.instance.get_request(token)
if request is not None:
return Response(*await frontend.read("/auth/restricted/index.html"))
# Otherwise, serve the reset app (it will validate the token via API)
return Response(*await frontend.read("/int/reset/index.html"))
+1 -1
View File
@@ -55,7 +55,7 @@ class RemoteAuthRequest:
def _generate_pairing_code() -> str:
"""Generate a short, easy-to-communicate pairing code using words.
DEPRECATED: Now we use the first 3 words of the main token instead.
"""
return passphrase.generate(n=PAIRING_CODE_WORDS)