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