Massive refactoring of remote auth and related functionality. (WIP)
This commit is contained in:
@@ -3,7 +3,6 @@ import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import Breadcrumbs from '@/components/Breadcrumbs.vue'
|
||||
import CredentialList from '@/components/CredentialList.vue'
|
||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||
import StatusMessage from '@/components/StatusMessage.vue'
|
||||
import LoadingView from '@/components/LoadingView.vue'
|
||||
import AuthRequiredMessage from '@/components/AccessDenied.vue'
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
<template>
|
||||
<!-- Remote auth completion mode -->
|
||||
<RemoteAuthComplete
|
||||
v-if="remoteAuthToken"
|
||||
:token="remoteAuthToken"
|
||||
@back="handleBack"
|
||||
@completed="handleRemoteCompleted"
|
||||
/>
|
||||
<!-- Normal restricted auth mode -->
|
||||
<RestrictedAuth
|
||||
v-else
|
||||
:mode="authMode"
|
||||
:remote-auth-token="remoteAuthToken"
|
||||
@authenticated="handleAuthenticated"
|
||||
@back="handleBack"
|
||||
/>
|
||||
@@ -18,19 +10,18 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import RestrictedAuth from '@/components/RestrictedAuth.vue'
|
||||
import RemoteAuthComplete from '@/components/RemoteAuthComplete.vue'
|
||||
|
||||
// Check if this is a remote auth URL: /{token} or /auth/{token}
|
||||
// Check if this is a remote auth URL: /auth/{token}
|
||||
// The token is a 5-word passphrase like "word1.word2.word3.word4.word5"
|
||||
const remoteAuthToken = ref(null)
|
||||
|
||||
function extractRemoteToken() {
|
||||
const path = window.location.pathname
|
||||
// Match /{token} or /auth/{token} where token is a passphrase with dots
|
||||
const match = path.match(/\/(?:auth\/)?([^/]+)$/)
|
||||
// Match /auth/{token} where token is a passphrase with dots
|
||||
const match = path.match(/\/auth\/([^/]+)$/)
|
||||
if (match) {
|
||||
const token = match[1]
|
||||
// Validate it looks like a passphrase (contains dots, likely 5 words)
|
||||
// Validate it looks like a 5-word passphrase
|
||||
const parts = token.split('.')
|
||||
if (parts.length === 5 && parts.every(p => p.length > 0)) {
|
||||
return token
|
||||
@@ -68,11 +59,6 @@ function handleBack() {
|
||||
})
|
||||
}
|
||||
|
||||
function handleRemoteCompleted() {
|
||||
// Remote auth completed - the other device is now logged in
|
||||
// This device doesn't need to do anything special
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Check for remote auth token in URL
|
||||
remoteAuthToken.value = extractRemoteToken()
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
|
||||
<section class="section-block" v-else-if="!canRegister">
|
||||
<div class="section-body center">
|
||||
<p>{{ errorMessage }}</p>
|
||||
<div class="button-row center" style="justify-content: center;">
|
||||
<button class="btn-secondary" @click="goHome">Return to sign-in</button>
|
||||
</div>
|
||||
@@ -80,7 +79,7 @@ let statusTimer = null
|
||||
const sessionDescriptor = computed(() => userInfo.value?.session_type || 'your enrollment')
|
||||
const subtitleMessage = computed(() => {
|
||||
if (initializing.value) return 'Preparing your secure enrollment…'
|
||||
if (!canRegister.value) return 'This reset link is no longer valid.'
|
||||
if (!canRegister.value) return 'This authentication link is no longer valid.'
|
||||
return `Finish up ${sessionDescriptor.value}. You may edit the name below if needed, and it will be saved to your passkey.`
|
||||
})
|
||||
|
||||
@@ -120,10 +119,9 @@ async function fetchUserInfo() {
|
||||
} catch (error) {
|
||||
console.error('Failed to load user info', error)
|
||||
const message = error instanceof ApiError
|
||||
? (error.data?.detail || 'Reset link is invalid or expired.')
|
||||
? (error.data?.detail || 'The authentication link is invalid or expired.')
|
||||
: getUserFriendlyErrorMessage(error)
|
||||
errorMessage.value = message
|
||||
showMessage(message, 'error', 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +132,6 @@ async function handleTerminateSession(session) {
|
||||
<RegistrationLinkModal
|
||||
v-if="showRegModal"
|
||||
:endpoint="`/auth/api/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`"
|
||||
:auto-copy="false"
|
||||
:user-name="userDetail?.display_name || selectedUser.display_name"
|
||||
@close="$emit('closeRegModal')"
|
||||
@copied="onLinkCopied"
|
||||
|
||||
@@ -422,9 +422,9 @@ th {
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 0.75rem;
|
||||
background: var(--color-surface);
|
||||
padding: 1rem;
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.link-container,
|
||||
|
||||
@@ -4,18 +4,17 @@
|
||||
<h1>📱 Add Another Device</h1>
|
||||
<p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p>
|
||||
</header>
|
||||
<RegistrationLinkModal
|
||||
inline
|
||||
:endpoint="'/auth/api/user/create-link'"
|
||||
:user-name="userName"
|
||||
:auto-copy="false"
|
||||
:prefix-copy-with-user-name="!!userName"
|
||||
show-close-in-inline
|
||||
@copied="onCopied"
|
||||
/>
|
||||
<div class="button-row" style="margin-top:1rem;">
|
||||
<button @click="showModal = true" class="btn-primary">Generate Registration Link</button>
|
||||
<button @click="authStore.currentView = 'profile'" class="btn-secondary">Back to Profile</button>
|
||||
</div>
|
||||
<RegistrationLinkModal
|
||||
v-if="showModal"
|
||||
endpoint="/auth/api/user/create-link"
|
||||
:user-name="userName"
|
||||
@close="showModal = false"
|
||||
@copied="onCopied"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -26,9 +25,10 @@ import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const userName = ref(null)
|
||||
const showModal = ref(false)
|
||||
|
||||
const onCopied = () => {
|
||||
authStore.showMessage('Link copied to clipboard!', 'success', 2500)
|
||||
authStore.currentView = 'profile'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -1,913 +0,0 @@
|
||||
<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>
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<section class="view-root" data-view="profile">
|
||||
<header class="view-header">
|
||||
<h1>👋 Welcome!</h1>
|
||||
<h1>User Profile</h1>
|
||||
<Breadcrumbs :entries="breadcrumbEntries" />
|
||||
<p class="view-lede">Manage your account details and passkeys.</p>
|
||||
<p class="view-lede">Account dashboard for managing credentials and authenticating with other devices.</p>
|
||||
</header>
|
||||
|
||||
<section class="section-block">
|
||||
@@ -17,7 +17,20 @@
|
||||
update-endpoint="/auth/api/user/display-name"
|
||||
@saved="authStore.loadUserInfo()"
|
||||
@edit-name="openNameDialog"
|
||||
>
|
||||
<div class="remote-auth-inline">
|
||||
<label v-if="!showDeviceInfo" class="remote-auth-label">Code words from remote device:</label>
|
||||
<RemoteAuth
|
||||
ref="pairingEntry"
|
||||
title=""
|
||||
description=""
|
||||
placeholder="word word word"
|
||||
@completed="handlePairingCompleted"
|
||||
@error="handlePairingError"
|
||||
@device-info-visible="showDeviceInfo = $event"
|
||||
/>
|
||||
</div>
|
||||
</UserBasicInfo>
|
||||
</section>
|
||||
|
||||
<section class="section-block">
|
||||
@@ -52,23 +65,6 @@
|
||||
section-description="Review where you're signed in and end any sessions you no longer recognize."
|
||||
/>
|
||||
|
||||
<section class="section-block">
|
||||
<div class="section-header">
|
||||
<h2>Help Another Device Sign In</h2>
|
||||
<p class="section-description">Enter a code from another device to sign it in using your passkey.</p>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<PairingCodeEntry
|
||||
ref="pairingEntry"
|
||||
title=""
|
||||
description=""
|
||||
placeholder="word word word"
|
||||
@completed="handlePairingCompleted"
|
||||
@error="handlePairingError"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Modal v-if="showNameDialog" @close="showNameDialog = false">
|
||||
<h3>Edit Display Name</h3>
|
||||
<form @submit.prevent="saveName" class="modal-form">
|
||||
@@ -101,11 +97,8 @@
|
||||
</section>
|
||||
<RegistrationLinkModal
|
||||
v-if="showRegLink"
|
||||
:endpoint="'/auth/api/user/create-link'"
|
||||
:auto-copy="false"
|
||||
:prefix-copy-with-user-name="false"
|
||||
endpoint="/auth/api/user/create-link"
|
||||
@close="showRegLink = false"
|
||||
@copied="showRegLink = false; authStore.showMessage('Link copied to clipboard!', 'success', 2500)"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -119,7 +112,7 @@ import Modal from '@/components/Modal.vue'
|
||||
import NameEditForm from '@/components/NameEditForm.vue'
|
||||
import SessionList from '@/components/SessionList.vue'
|
||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||
import PairingCodeEntry from '@/components/PairingCodeEntry.vue'
|
||||
import RemoteAuth from '@/components/RemoteAuthPermit.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { adminUiPath, makeUiHref } from '@/utils/settings'
|
||||
import passkey from '@/utils/passkey'
|
||||
@@ -134,6 +127,7 @@ const newName = ref('')
|
||||
const saving = ref(false)
|
||||
const hoveredCredentialUuid = ref(null)
|
||||
const hoveredSession = ref(null)
|
||||
const showDeviceInfo = ref(false)
|
||||
const pairingEntry = ref(null)
|
||||
|
||||
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.user?.user_name || '' })
|
||||
@@ -231,5 +225,7 @@ const saveName = async () => {
|
||||
.logout-row { gap: 1rem; }
|
||||
.logout-row.single { justify-content: flex-start; }
|
||||
.logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; }
|
||||
.remote-auth-inline { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.remote-auth-label { display: block; margin: 0; font-size: 0.875rem; color: var(--color-text-muted); font-weight: 500; }
|
||||
@media (max-width: 720px) { .logout-button { width: 100%; } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div class="qr-display">
|
||||
<div class="qr-section">
|
||||
<a :href="url" @click.prevent="copyLink" class="qr-link" title="Click to copy link">
|
||||
<canvas ref="qrCanvas" class="qr-code"></canvas>
|
||||
<div v-if="showLink && url" class="link-text">{{ displayUrl }}</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-if="showCopyToast" class="copy-toast">
|
||||
✓ Link copied to clipboard
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick, computed } from 'vue'
|
||||
import QRCode from 'qrcode/lib/browser'
|
||||
|
||||
const props = defineProps({
|
||||
url: { type: String, required: true },
|
||||
showLink: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['copied'])
|
||||
|
||||
const qrCanvas = ref(null)
|
||||
const showCopyToast = ref(false)
|
||||
|
||||
let copyToastTimer = null
|
||||
|
||||
const displayUrl = computed(() => {
|
||||
if (!props.url) return ''
|
||||
return props.url.replace(/^https?:\/\//, '')
|
||||
})
|
||||
|
||||
function drawQR() {
|
||||
if (!props.url || !qrCanvas.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Clear the canvas first
|
||||
const ctx = qrCanvas.value.getContext('2d')
|
||||
ctx.clearRect(0, 0, qrCanvas.value.width, qrCanvas.value.height)
|
||||
|
||||
// Generate QR code synchronously
|
||||
QRCode.toCanvas(qrCanvas.value, props.url, {
|
||||
scale: 6,
|
||||
margin: 0,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
}
|
||||
})
|
||||
|
||||
// Remove any inline styles added by QRCode library immediately
|
||||
qrCanvas.value.removeAttribute('style')
|
||||
} catch (err) {
|
||||
console.error('QR code generation failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
if (!props.url) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.url)
|
||||
showCopyToast.value = true
|
||||
emit('copied')
|
||||
|
||||
if (copyToastTimer) clearTimeout(copyToastTimer)
|
||||
copyToastTimer = setTimeout(() => {
|
||||
showCopyToast.value = false
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy link:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for URL changes
|
||||
watch(() => props.url, () => {
|
||||
drawQR()
|
||||
}, { immediate: true })
|
||||
|
||||
// Watch for canvas ref becoming available
|
||||
watch(qrCanvas, () => {
|
||||
if (qrCanvas.value && props.url) {
|
||||
drawQR()
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.qr-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.qr-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.qr-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
display: block;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-text {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
line-height: 1.2;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.qr-link:hover .link-text {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.copy-toast {
|
||||
position: absolute;
|
||||
top: -2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--color-success);
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
z-index: 10;
|
||||
animation: fadeInOut 2s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInOut {
|
||||
0%, 100% { opacity: 0; }
|
||||
10%, 90% { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,147 +1,109 @@
|
||||
<template>
|
||||
<div v-if="!inline && url" class="dialog-overlay" @keydown.esc.prevent="$emit('close')">
|
||||
<div class="dialog-overlay" @keydown.esc.prevent="$emit('close')">
|
||||
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
|
||||
<div class="reg-header-row">
|
||||
<h2 id="regTitle" class="reg-title">
|
||||
📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Device Registration Link</span>
|
||||
📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Add Another Device</span>
|
||||
</h2>
|
||||
<button class="icon-btn" @click="$emit('close')" aria-label="Close">❌</button>
|
||||
</div>
|
||||
|
||||
<div class="device-link-section">
|
||||
<div class="qr-container">
|
||||
<a :href="url" @click.prevent="copy" class="qr-link">
|
||||
<canvas ref="qrCanvas" class="qr-code"></canvas>
|
||||
<p>{{ displayUrl }}</p>
|
||||
</a>
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="spinner-small"></div>
|
||||
<span>Generating registration link...</span>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="error-state">
|
||||
<p class="error-message">{{ error }}</p>
|
||||
<button class="btn-secondary" @click="generateLink">Retry</button>
|
||||
</div>
|
||||
|
||||
<!-- Success state with QR code and link -->
|
||||
<template v-else-if="linkUrl">
|
||||
<p class="reg-help">
|
||||
<span v-if="userName">The user should open this link on the device where they want to register.</span>
|
||||
<span v-else>Open or scan this link on the device you wish to register to your account.</span>
|
||||
<br><small>{{ expirationMessage }}</small>
|
||||
Scan this QR code on the new device, or copy the link and open it there.
|
||||
</p>
|
||||
|
||||
<QRCodeDisplay
|
||||
:url="linkUrl"
|
||||
:show-link="true"
|
||||
@copied="onCopied"
|
||||
/>
|
||||
|
||||
<p class="expiry-note" v-if="expiresAt">
|
||||
This link expires {{ formatDate(expiresAt).toLowerCase() }}.
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="reg-actions">
|
||||
<button class="btn-secondary" @click="$emit('close')">Close</button>
|
||||
<button class="btn-primary" @click="copy">Copy Link</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="inline && url" class="registration-inline-wrapper">
|
||||
<div class="registration-inline-block section-block">
|
||||
<div class="section-header">
|
||||
<h2 class="inline-heading">📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Device Registration Link</span></h2>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<div class="device-link-section">
|
||||
<div class="qr-container">
|
||||
<a :href="url" @click.prevent="copy" class="qr-link">
|
||||
<canvas ref="qrCanvas" class="qr-code"></canvas>
|
||||
<p>{{ displayUrl }}</p>
|
||||
</a>
|
||||
<p class="reg-help">
|
||||
<span v-if="userName">The user should open this link on the device where they want to register.</span>
|
||||
<span v-else>Open this link on the device you wish to connect with.</span>
|
||||
<br><small>{{ expirationMessage }}</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-row" style="margin-top:1rem;">
|
||||
<button class="btn-primary" @click="copy">Copy Link</button>
|
||||
<button v-if="showCloseInInline" class="btn-secondary" @click="$emit('close')">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, computed, nextTick } from 'vue'
|
||||
import QRCode from 'qrcode/lib/browser'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
|
||||
import { apiJson } from '@/utils/api'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { apiJson, getUserFriendlyErrorMessage, shouldShowErrorToast } from '@/utils/api'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const props = defineProps({
|
||||
endpoint: { type: String, required: true },
|
||||
autoCopy: { type: Boolean, default: true },
|
||||
userName: { type: String, default: null },
|
||||
inline: { type: Boolean, default: false },
|
||||
showCloseInInline: { type: Boolean, default: false },
|
||||
prefixCopyWithUserName: { type: Boolean, default: false }
|
||||
userName: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close','generated','copied'])
|
||||
const emit = defineEmits(['close', 'copied'])
|
||||
|
||||
const url = ref(null)
|
||||
const expires = ref(null)
|
||||
const qrCanvas = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const linkUrl = ref(null)
|
||||
const expiresAt = ref(null)
|
||||
|
||||
const displayUrl = computed(() => url.value ? url.value.replace(/^[^:]+:\/\//,'') : '')
|
||||
async function generateLink() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
linkUrl.value = null
|
||||
expiresAt.value = null
|
||||
|
||||
const expirationMessage = computed(() => {
|
||||
const timeStr = formatDate(expires.value)
|
||||
return `⚠️ Expires ${timeStr.startsWith('In ') ? timeStr.substring(3) : timeStr} and can only be used once.`
|
||||
})
|
||||
|
||||
async function fetchLink() {
|
||||
try {
|
||||
const data = await apiJson(props.endpoint, { method: 'POST' })
|
||||
url.value = data.url
|
||||
expires.value = data.expires
|
||||
emit('generated', { url: data.url, expires: data.expires })
|
||||
await nextTick()
|
||||
drawQR()
|
||||
if (props.autoCopy) copy()
|
||||
} catch (e) {
|
||||
console.error('Failed to create link', e)
|
||||
if (shouldShowErrorToast(e)) {
|
||||
authStore.showMessage(getUserFriendlyErrorMessage(e), 'error', 4000)
|
||||
if (data.url) {
|
||||
linkUrl.value = data.url
|
||||
expiresAt.value = data.expires ? new Date(data.expires) : null
|
||||
} else {
|
||||
error.value = data.detail || 'Failed to generate link'
|
||||
}
|
||||
// Close the dialog on any error (auth cancelled, network error, etc.)
|
||||
emit('close')
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Failed to generate link'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function drawQR() {
|
||||
if (!url.value) return
|
||||
await nextTick()
|
||||
if (!qrCanvas.value) return
|
||||
QRCode.toCanvas(qrCanvas.value, url.value, { scale: 8 }, err => { if (err) console.error(err) })
|
||||
function onCopied() {
|
||||
emit('copied')
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
if (!url.value) return
|
||||
let text = url.value
|
||||
if (props.prefixCopyWithUserName && props.userName) {
|
||||
text = `${props.userName} ${text}`
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
emit('copied', text)
|
||||
if (!props.inline) emit('close')
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchLink)
|
||||
watch(url, () => drawQR(), { flush: 'post' })
|
||||
|
||||
onMounted(() => {
|
||||
generateLink()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.icon-btn { background:none; border:none; cursor:pointer; font-size:1rem; opacity:.6; }
|
||||
.icon-btn:hover { opacity:1; }
|
||||
/* Minimal extra styling; main look comes from global styles */
|
||||
.qr-link { text-decoration:none; color:inherit; }
|
||||
.reg-header-row { display:flex; justify-content:space-between; align-items:center; gap:.75rem; margin-bottom:.75rem; }
|
||||
.reg-title { margin:0; font-size:1.25rem; font-weight:600; }
|
||||
.device-dialog { background: var(--color-surface); padding: 1.25rem 1.25rem 1rem; border-radius: var(--radius-md); max-width:480px; width:100%; box-shadow:0 6px 28px rgba(0,0,0,.25); }
|
||||
.qr-container { display:flex; flex-direction:column; align-items:center; gap:.5rem; }
|
||||
.qr-code { display:block; }
|
||||
.reg-help { margin-top:.5rem; margin-bottom:.75rem; font-size:.85rem; line-height:1.25rem; text-align:center; }
|
||||
.reg-actions { display:flex; justify-content:flex-end; gap:.5rem; margin-top:.25rem; }
|
||||
.registration-inline-block .qr-container { align-items:flex-start; }
|
||||
.registration-inline-block .reg-help { text-align:left; }
|
||||
.icon-btn { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: .6; }
|
||||
.icon-btn:hover { opacity: 1; }
|
||||
.reg-header-row { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-bottom: .75rem; }
|
||||
.reg-title { margin: 0; font-size: 1.25rem; font-weight: 600; }
|
||||
.device-dialog { background: var(--color-surface); padding: 1.25rem 1.25rem 1rem; border-radius: var(--radius-md); max-width: 480px; width: 100%; box-shadow: 0 6px 28px rgba(0,0,0,.25); }
|
||||
.reg-help { margin: .5rem 0 .75rem; font-size: .85rem; line-height: 1.4; text-align: center; color: var(--color-text-muted); }
|
||||
.reg-actions { display: flex; justify-content: flex-end; gap: .5rem; margin-top: 1rem; }
|
||||
.loading-state { display: flex; align-items: center; justify-content: center; gap: .5rem; padding: 2rem 0; color: var(--color-text-muted); }
|
||||
.error-state { text-align: center; padding: 1rem 0; }
|
||||
.error-message { color: var(--color-danger-text); margin-bottom: 1rem; }
|
||||
.expiry-note { font-size: .75rem; color: var(--color-text-muted); text-align: center; margin-top: .75rem; }
|
||||
</style>
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<div v-if="status.show" class="global-status" style="display: block;">
|
||||
<div :class="['status', status.type]">
|
||||
{{ status.message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="view-root">
|
||||
<div class="surface surface--tight">
|
||||
<header class="view-header center">
|
||||
<h1>📱 Remote Login</h1>
|
||||
<p class="view-lede">{{ subtitleMessage }}</p>
|
||||
</header>
|
||||
|
||||
<section class="section-block">
|
||||
<div class="section-body center">
|
||||
<!-- Loading/Initializing -->
|
||||
<div v-if="initializing" class="loading-section">
|
||||
<p>Connecting…</p>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="error-section">
|
||||
<p class="error-message">{{ error }}</p>
|
||||
<div class="button-row center">
|
||||
<button class="btn-secondary" @click="goHome">Return to sign-in</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Completed state -->
|
||||
<div v-else-if="completed" class="success-section">
|
||||
<p class="success-message">✅ {{ completedMessage }}</p>
|
||||
<p class="help-text">You can now close this window.</p>
|
||||
<div class="button-row center">
|
||||
<button class="btn-secondary" @click="goHome">Go to Profile</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ready to authenticate -->
|
||||
<div v-else class="auth-section">
|
||||
<p class="help-text">
|
||||
Use your passkey on this device to log in on another device.
|
||||
</p>
|
||||
<div class="button-row center">
|
||||
<button class="btn-secondary" :disabled="loading" @click="$emit('back')">Cancel</button>
|
||||
<button class="btn-primary" :disabled="loading" @click="authenticate">
|
||||
{{ loading ? 'Authenticating…' : 'Authenticate with Passkey' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, reactive, 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, uiBasePath } from '@/utils/settings'
|
||||
import { solvePoW } from '@/utils/pow'
|
||||
|
||||
const props = defineProps({
|
||||
token: { type: String, required: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['back', 'completed'])
|
||||
|
||||
const status = reactive({ show: false, message: '', type: 'info' })
|
||||
const initializing = ref(true)
|
||||
const loading = ref(false)
|
||||
const settings = ref(null)
|
||||
const error = ref(null)
|
||||
const completed = ref(false)
|
||||
const completedMessage = ref('')
|
||||
let statusTimer = null
|
||||
let ws = null
|
||||
|
||||
const subtitleMessage = computed(() => {
|
||||
if (initializing.value) return 'Preparing secure remote authentication…'
|
||||
if (error.value) return 'This remote login link is no longer valid.'
|
||||
if (completed.value) return 'Remote login successful!'
|
||||
return 'Authenticate here to log in on another device.'
|
||||
})
|
||||
|
||||
function showMessage(message, type = 'info', duration = 3000) {
|
||||
status.show = true
|
||||
status.message = message
|
||||
status.type = type
|
||||
if (statusTimer) clearTimeout(statusTimer)
|
||||
if (duration > 0) {
|
||||
statusTimer = setTimeout(() => { status.show = false }, duration)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSettings() {
|
||||
try {
|
||||
const data = await getSettings()
|
||||
settings.value = data
|
||||
if (data?.rp_name) document.title = `${data.rp_name} · Remote Login`
|
||||
} catch (err) {
|
||||
console.warn('Unable to load settings', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticate() {
|
||||
if (loading.value || !props.token) return
|
||||
loading.value = true
|
||||
showMessage('Starting authentication…', 'info')
|
||||
|
||||
try {
|
||||
// Connect to the remote auth completion WebSocket
|
||||
const authHost = settings.value?.auth_host
|
||||
const wsPath = `/auth/ws/remote-auth/complete/${encodeURIComponent(props.token)}`
|
||||
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
|
||||
|
||||
ws = await aWebSocket(wsUrl)
|
||||
|
||||
// First message is PoW challenge
|
||||
const powChallenge = await ws.receive_json()
|
||||
if (powChallenge.pow) {
|
||||
const challenge = b64dec(powChallenge.pow.challenge)
|
||||
const powStart = performance.now()
|
||||
const nonces = await solvePoW(challenge, powChallenge.pow.work)
|
||||
const powTime = performance.now() - powStart
|
||||
console.log(`PoW solved: ${powChallenge.pow.work} work units in ${(powTime / 1000).toFixed(3)}s`)
|
||||
ws.send_json({ pow: b64enc(nonces) })
|
||||
}
|
||||
|
||||
// Receive authentication options
|
||||
const res = await ws.receive_json()
|
||||
|
||||
if (res.status) {
|
||||
throw new Error(res.detail || `Authentication failed: ${res.status}`)
|
||||
}
|
||||
|
||||
// Perform WebAuthn authentication (options are in optionsJSON field)
|
||||
const authResponse = await startAuthentication(res.optionsJSON || res)
|
||||
ws.send_json(authResponse)
|
||||
|
||||
// Wait for confirmation
|
||||
const result = await ws.receive_json()
|
||||
|
||||
if (result.status === 'success') {
|
||||
completed.value = true
|
||||
completedMessage.value = result.message || 'The other device is now logged in.'
|
||||
showMessage('Authentication successful!', 'success', 3000)
|
||||
emit('completed')
|
||||
} else {
|
||||
throw new Error(result.detail || 'Remote authentication failed')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Remote authentication error:', err)
|
||||
const message = err.name === 'NotAllowedError'
|
||||
? 'Passkey authentication cancelled'
|
||||
: (err.message || 'Authentication failed')
|
||||
const cancelled = message === 'Passkey authentication cancelled'
|
||||
showMessage(message, cancelled ? 'info' : 'error', 4000)
|
||||
if (!cancelled) {
|
||||
error.value = message
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
const target = uiBasePath() || '/auth/'
|
||||
window.location.href = target
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchSettings()
|
||||
initializing.value = false
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.center { text-align: center; }
|
||||
.button-row.center { display: flex; justify-content: center; gap: 0.75rem; }
|
||||
|
||||
main.view-root {
|
||||
min-height: 100vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.surface.surface--tight {
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.95rem;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-success, #10b981);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-error, #ef4444);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.loading-section, .auth-section, .success-section, .error-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,430 +0,0 @@
|
||||
<template>
|
||||
<!-- Dialog mode (modal overlay) -->
|
||||
<div v-if="!inline && active" class="dialog-overlay" @keydown.esc.prevent="cancel">
|
||||
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="remoteTitle">
|
||||
<div class="reg-header-row">
|
||||
<h2 id="remoteTitle" class="reg-title">📱 Login from Another Device</h2>
|
||||
<button class="icon-btn" @click="cancel" aria-label="Close">❌</button>
|
||||
</div>
|
||||
|
||||
<!-- QR/Link display while waiting -->
|
||||
<div v-if="url && !completed && !error" class="device-link-section">
|
||||
<div class="qr-container">
|
||||
<div v-if="pairingCode" class="pairing-code-section">
|
||||
<p class="pairing-label">Enter this code on a device with your passkey:</p>
|
||||
<div class="pairing-code" @click="copyCode">{{ displayCode }}</div>
|
||||
</div>
|
||||
<div class="qr-divider"><span>or scan</span></div>
|
||||
<a :href="url" @click.prevent="copy" class="qr-link">
|
||||
<canvas ref="qrCanvas" class="qr-code"></canvas>
|
||||
</a>
|
||||
<p class="reg-help"><small>{{ expirationMessage }}</small></p>
|
||||
</div>
|
||||
<div class="waiting-indicator">
|
||||
<div class="spinner"></div>
|
||||
<span>Waiting for authentication…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success state -->
|
||||
<div v-else-if="completed" class="success-section">
|
||||
<p class="success-message">✅ Authenticated successfully!</p>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="error-section">
|
||||
<p class="error-message">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-else class="loading-section">
|
||||
<div class="spinner"></div>
|
||||
<p>Generating remote login link…</p>
|
||||
</div>
|
||||
|
||||
<div class="reg-actions">
|
||||
<button v-if="!completed" class="btn-secondary" @click="cancel">Cancel</button>
|
||||
<button v-if="url && !completed && !error" class="btn-primary" @click="copy">Copy Link</button>
|
||||
<button v-if="error" class="btn-primary" @click="retry">Try Again</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inline mode (embedded in page) -->
|
||||
<div v-else-if="inline && active" class="registration-inline-wrapper">
|
||||
<div class="registration-inline-block section-block">
|
||||
<div class="section-header">
|
||||
<h2 class="inline-heading">📱 Login from Another Device</h2>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<!-- QR/Link display while waiting -->
|
||||
<div v-if="url && !completed && !error" class="device-link-section">
|
||||
<div class="qr-container">
|
||||
<div v-if="pairingCode" class="pairing-code-section">
|
||||
<p class="pairing-label">Enter this code on a device with your passkey:</p>
|
||||
<div class="pairing-code" @click="copyCode">{{ displayCode }}</div>
|
||||
</div>
|
||||
<div class="qr-divider"><span>or scan</span></div>
|
||||
<a :href="url" @click.prevent="copy" class="qr-link">
|
||||
<canvas ref="qrCanvas" class="qr-code"></canvas>
|
||||
</a>
|
||||
<p class="reg-help"><small>{{ expirationMessage }}</small></p>
|
||||
</div>
|
||||
<div class="waiting-indicator">
|
||||
<div class="spinner"></div>
|
||||
<span>Waiting for authentication…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success state -->
|
||||
<div v-else-if="completed" class="success-section">
|
||||
<p class="success-message">✅ Authenticated successfully!</p>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="error-section">
|
||||
<p class="error-message">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-else class="loading-section">
|
||||
<div class="spinner"></div>
|
||||
<p>Generating remote login link…</p>
|
||||
</div>
|
||||
|
||||
<div class="button-row" style="margin-top:1rem;">
|
||||
<button v-if="url && !completed && !error" class="btn-primary" @click="copy">Copy Link</button>
|
||||
<button v-if="!completed" class="btn-secondary" @click="cancel">Cancel</button>
|
||||
<button v-if="error" class="btn-primary" @click="retry">Try Again</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import QRCode from 'qrcode/lib/browser'
|
||||
import aWebSocket from '@/utils/awaitable-websocket'
|
||||
import { dec as b64dec, enc as b64enc } from '@/utils/base64url'
|
||||
import { getSettings } from '@/utils/settings'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
import { solvePoW } from '@/utils/pow'
|
||||
|
||||
const props = defineProps({
|
||||
active: { type: Boolean, default: false },
|
||||
inline: { type: Boolean, default: false },
|
||||
autoCopy: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['authenticated', 'cancelled', 'error', 'close', 'copied'])
|
||||
|
||||
const url = ref(null)
|
||||
const pairingCode = ref(null)
|
||||
const expires = ref(null)
|
||||
const qrCanvas = ref(null)
|
||||
const completed = ref(false)
|
||||
const error = ref(null)
|
||||
let ws = null
|
||||
|
||||
const displayUrl = computed(() => url.value ? url.value.replace(/^[^:]+:\/\//, '') : '')
|
||||
|
||||
// Display pairing code as space-separated words for readability
|
||||
const displayCode = computed(() => pairingCode.value ? pairingCode.value.replace(/\./g, ' ') : '')
|
||||
|
||||
const expirationMessage = computed(() => {
|
||||
if (!expires.value) return ''
|
||||
const timeStr = formatDate(expires.value)
|
||||
return `⚠️ Expires ${timeStr.startsWith('In ') ? timeStr.substring(3) : timeStr}.`
|
||||
})
|
||||
|
||||
async function startRemoteAuth() {
|
||||
error.value = null
|
||||
completed.value = false
|
||||
url.value = null
|
||||
pairingCode.value = null
|
||||
expires.value = null
|
||||
|
||||
try {
|
||||
// Get auth_host from settings to construct proper WebSocket URL
|
||||
const settings = await getSettings()
|
||||
const authHost = settings?.auth_host
|
||||
const wsPath = '/auth/ws/remote-auth/request'
|
||||
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
|
||||
|
||||
ws = await aWebSocket(wsUrl)
|
||||
|
||||
// First message is PoW challenge
|
||||
const powChallenge = await ws.receive_json()
|
||||
if (powChallenge.pow) {
|
||||
const challenge = b64dec(powChallenge.pow.challenge)
|
||||
const powStart = performance.now()
|
||||
const nonces = await solvePoW(challenge, powChallenge.pow.work)
|
||||
const powTime = performance.now() - powStart
|
||||
console.log(`PoW solved: ${powChallenge.pow.work} work units in ${(powTime / 1000).toFixed(3)}s`)
|
||||
ws.send_json({ pow: b64enc(nonces) })
|
||||
}
|
||||
|
||||
// Receive the remote auth token, pairing code, and URL
|
||||
const res = await ws.receive_json()
|
||||
|
||||
if (res.status) {
|
||||
throw new Error(res.detail || `Failed to create remote auth request: ${res.status}`)
|
||||
}
|
||||
|
||||
url.value = res.url
|
||||
pairingCode.value = res.pairing_code
|
||||
expires.value = res.expires
|
||||
|
||||
await nextTick()
|
||||
drawQR()
|
||||
|
||||
if (props.autoCopy) copy()
|
||||
|
||||
// Now wait for authentication
|
||||
waitForAuth()
|
||||
} catch (err) {
|
||||
console.error('Remote auth error:', err)
|
||||
error.value = err.message || 'Failed to start remote authentication'
|
||||
emit('error', error.value)
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForAuth() {
|
||||
if (!ws) return
|
||||
|
||||
try {
|
||||
const result = await ws.receive_json()
|
||||
|
||||
if (result.status === 'authenticated') {
|
||||
completed.value = true
|
||||
emit('authenticated', {
|
||||
session_token: result.session_token,
|
||||
user_uuid: result.user_uuid
|
||||
})
|
||||
} else if (result.status === 'expired' || result.status === 'cancelled') {
|
||||
error.value = result.detail || 'Remote authentication was cancelled or expired'
|
||||
emit('cancelled')
|
||||
} else {
|
||||
error.value = result.detail || 'Remote authentication failed'
|
||||
emit('error', error.value)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!completed.value) {
|
||||
error.value = err.message || 'Connection lost'
|
||||
emit('error', error.value)
|
||||
}
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
function drawQR() {
|
||||
if (!url.value) return
|
||||
nextTick(() => {
|
||||
if (!qrCanvas.value) return
|
||||
QRCode.toCanvas(qrCanvas.value, url.value, { scale: 8 }, err => {
|
||||
if (err) console.error('QR code error:', err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
if (!url.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(url.value)
|
||||
emit('copied', url.value)
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCode() {
|
||||
if (!pairingCode.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(pairingCode.value)
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (ws) {
|
||||
try {
|
||||
ws.send_json({ action: 'cancel' })
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
cleanup()
|
||||
emit('cancelled')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function retry() {
|
||||
error.value = null
|
||||
startRemoteAuth()
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (ws) {
|
||||
try {
|
||||
ws.close()
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
ws = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.active, (newVal) => {
|
||||
if (newVal) {
|
||||
startRemoteAuth()
|
||||
} else {
|
||||
cleanup()
|
||||
// Reset state when deactivated
|
||||
url.value = null
|
||||
pairingCode.value = null
|
||||
expires.value = null
|
||||
completed.value = false
|
||||
error.value = null
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.active) {
|
||||
startRemoteAuth()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
watch(url, () => drawQR(), { flush: 'post' })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.icon-btn { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: .6; }
|
||||
.icon-btn:hover { opacity: 1; }
|
||||
.qr-link { text-decoration: none; color: inherit; }
|
||||
.reg-header-row { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-bottom: .75rem; }
|
||||
.reg-title { margin: 0; font-size: 1.25rem; font-weight: 600; }
|
||||
.device-dialog { background: var(--color-surface); padding: 1.25rem 1.25rem 1rem; border-radius: var(--radius-md); max-width: 480px; width: 100%; box-shadow: 0 6px 28px rgba(0,0,0,.25); }
|
||||
.qr-container { display: flex; flex-direction: column; align-items: center; gap: .5rem; }
|
||||
.qr-code { display: block; }
|
||||
.reg-help { margin-top: .5rem; margin-bottom: .75rem; font-size: .85rem; line-height: 1.25rem; text-align: center; }
|
||||
.reg-actions { display: flex; justify-content: flex-end; gap: .5rem; margin-top: .25rem; }
|
||||
.registration-inline-block .qr-container { align-items: flex-start; }
|
||||
.registration-inline-block .reg-help { text-align: left; }
|
||||
|
||||
.pairing-code-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--color-surface-hover, rgba(0,0,0,0.05));
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pairing-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pairing-code {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.15s;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.pairing-code:hover {
|
||||
background: var(--color-surface-hover, rgba(0,0,0,0.1));
|
||||
}
|
||||
|
||||
.qr-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 0.5rem 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.qr-divider::before,
|
||||
.qr-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.qr-divider span {
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.waiting-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--color-surface-hover, rgba(0,0,0,0.05));
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.loading-section .spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.success-section, .error-section {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-success, #10b981);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-error, #ef4444);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,969 @@
|
||||
<template>
|
||||
<div class="pairing-entry">
|
||||
<form @submit.prevent="submitCode" class="pairing-form">
|
||||
<!-- Code input (only shown in pairing mode, not token mode) -->
|
||||
<div v-if="!deviceInfo && !props.token" class="input-row">
|
||||
<div class="input-wrapper" :class="{ 'has-error': serverError, 'is-complete': deviceInfo && !serverError, 'focused': isFocused }">
|
||||
<!-- Visual slot-machine display overlay -->
|
||||
<div class="slot-machine" :class="{ 'has-error': serverError, 'is-complete': deviceInfo && !serverError }" aria-hidden="true">
|
||||
<div v-for="(word, index) in displayWords" :key="index" class="slot-reel" :class="{ 'invalid-word': word.invalid, 'empty': !word.text && !word.typedPrefix }">
|
||||
<div class="slot-word">
|
||||
<template v-if="word.typedPrefix">
|
||||
<span class="typed-prefix">{{ word.typedPrefix }}</span><span class="hint-suffix">{{ word.hintSuffix }}</span>
|
||||
<span v-if="word.hasCursor" class="cursor-overlay" :style="{ '--cursor-pos': word.cursorCharIndex, '--word-len': word.wordLen }"></span>
|
||||
</template>
|
||||
<template v-else-if="word.text">
|
||||
{{ word.text }}
|
||||
<span v-if="word.hasCursor" class="cursor-overlay" :style="{ '--cursor-pos': word.cursorCharIndex, '--word-len': word.wordLen }"></span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="word.hasCursor" class="cursor-overlay" :style="{ '--cursor-pos': 0, '--word-len': 0 }"></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hidden input for actual text entry -->
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="code"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="pairing-input hidden-input"
|
||||
@input="handleInput"
|
||||
@keydown="deferUpdateCursor"
|
||||
@mouseup="updateCursorPos"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
/>
|
||||
</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-else-if="deviceInfo" class="device-info">
|
||||
<p class="device-permit-text">Permit {{ deviceInfo.action === 'register' ? 'registration' : 'login' }} to <strong>{{ deviceInfo.host }}</strong></p>
|
||||
<p class="device-meta">{{ deviceInfo.user_agent_pretty }}</p>
|
||||
|
||||
<p v-if="error" class="error-message" style="margin-top: 0.5rem;">{{ error }}</p>
|
||||
|
||||
<div class="button-row" style="margin-top: 0.75rem; display: flex; gap: 0.5rem;">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
:disabled="loading"
|
||||
@click="deny"
|
||||
style="flex: 1;"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
<button
|
||||
ref="submitBtnRef"
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="btn-primary"
|
||||
style="flex: 1;"
|
||||
>
|
||||
{{ loading ? 'Authenticating…' : 'Authorize' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error && !deviceInfo" class="error-message">{{ error }}</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } 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 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' },
|
||||
action: { type: String, default: 'login' }, // 'login' or 'register'
|
||||
token: { type: String, default: null } // 5-word token for direct auth (skips code entry)
|
||||
})
|
||||
|
||||
const emit = defineEmits(['completed', 'error', 'cancelled', 'back', 'register', 'deviceInfoVisible'])
|
||||
|
||||
// State
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const settings = ref(null)
|
||||
let ws = null
|
||||
let authStore = null
|
||||
|
||||
// Try to get authStore (might fail if Pinia not installed in this app instance)
|
||||
try { authStore = useAuthStore() } catch (e) { /* ignore */ }
|
||||
|
||||
const inputRef = ref(null)
|
||||
const submitBtnRef = ref(null)
|
||||
const code = ref('')
|
||||
const isProcessing = ref(false)
|
||||
const processingStatus = ref('')
|
||||
const deviceInfo = ref(null)
|
||||
const autocompleteHint = ref('')
|
||||
|
||||
// Watch deviceInfo and emit visibility change
|
||||
watch(deviceInfo, (newVal) => {
|
||||
emit('deviceInfoVisible', !!newVal)
|
||||
})
|
||||
|
||||
const hasInvalidWord = ref(false)
|
||||
const serverError = ref(false)
|
||||
const cursorPos = ref(0)
|
||||
const isFocused = ref(false)
|
||||
let wsConnecting = false
|
||||
let currentChallenge = null
|
||||
let currentWork = null
|
||||
let powPromise = null
|
||||
let powSolution = null
|
||||
let lookupTimeout = null
|
||||
let lastLookedUpCode = null
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function showMessage(message, type = 'info', duration = 3000) {
|
||||
if (authStore) {
|
||||
authStore.showMessage(message, type, duration)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSettings() {
|
||||
try {
|
||||
const data = await getSettings()
|
||||
settings.value = data
|
||||
} catch (err) {
|
||||
console.warn('Unable to load settings', err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Input Mode Logic ---
|
||||
|
||||
function getWordAtCursor(input, cursor) {
|
||||
if (!input || cursor < 0) return { word: '', start: 0, end: 0 }
|
||||
let start = cursor, end = cursor
|
||||
while (start > 0 && /[a-zA-Z]/.test(input[start - 1])) start--
|
||||
while (end < input.length && /[a-zA-Z]/.test(input[end])) end++
|
||||
return { word: input.slice(start, end), start, end }
|
||||
}
|
||||
|
||||
function getWords(input) {
|
||||
return input.trim().split(/[.\s]+/).filter(w => w.length > 0)
|
||||
}
|
||||
|
||||
function countCompleteWords(input) {
|
||||
const endsWithSeparator = /[.\s]$/.test(input)
|
||||
const words = getWords(input)
|
||||
return endsWithSeparator ? words.length : Math.max(0, words.length - 1)
|
||||
}
|
||||
|
||||
function analyzeWords(input) {
|
||||
if (!input) return { valid: true, segments: [] }
|
||||
const segments = []
|
||||
const endsWithSeparator = /[.\s]$/.test(input)
|
||||
let match, regex = /([a-zA-Z]+)|([.\s]+)/g
|
||||
while ((match = regex.exec(input)) !== null) {
|
||||
if (match[1]) segments.push({ text: match[1], isWord: true, start: match.index })
|
||||
else if (match[2]) segments.push({ text: match[2], isWord: false, start: match.index })
|
||||
}
|
||||
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) wordSeg.invalid = !isValidPrefix(word)
|
||||
else wordSeg.invalid = !isValidWord(word)
|
||||
if (wordSeg.invalid) allValid = false
|
||||
})
|
||||
return { valid: allValid, segments }
|
||||
}
|
||||
|
||||
const coloredSegments = computed(() => {
|
||||
const { segments } = analyzeWords(code.value)
|
||||
return segments.map(s => ({ text: s.text, invalid: s.invalid || false }))
|
||||
})
|
||||
|
||||
function checkWordsValidity(input) { return analyzeWords(input).valid }
|
||||
function allWordsValid(input) { return getWords(input).length > 0 && getWords(input).every(w => isValidWord(w)) }
|
||||
|
||||
// Get the current partial word being typed (not yet a complete word)
|
||||
function getCurrentPartialWord(input) {
|
||||
const endsWithSeparator = /[.\s]$/.test(input)
|
||||
if (endsWithSeparator) return ''
|
||||
const match = input.match(/[a-zA-Z]+$/)
|
||||
return match ? match[0].toLowerCase() : ''
|
||||
}
|
||||
|
||||
// Calculate cursor position in the normalized display (wordIndex, charIndex within word)
|
||||
// Returns { wordIndex: number, charIndex: number } where charIndex is position within the word text
|
||||
function calcDisplayCursor(input, rawCursorPos) {
|
||||
if (!input || rawCursorPos === 0) {
|
||||
return { wordIndex: 0, charIndex: 0 }
|
||||
}
|
||||
|
||||
// Parse input to find word boundaries
|
||||
const beforeCursor = input.slice(0, rawCursorPos)
|
||||
const wordMatches = [...beforeCursor.matchAll(/[a-zA-Z]+/g)]
|
||||
|
||||
// Check if cursor is in whitespace after words
|
||||
const endsWithSeparator = /[.\s]$/.test(beforeCursor)
|
||||
|
||||
if (wordMatches.length === 0) {
|
||||
// No words before cursor, cursor is at start of first word
|
||||
return { wordIndex: 0, charIndex: 0 }
|
||||
}
|
||||
|
||||
const lastMatch = wordMatches[wordMatches.length - 1]
|
||||
const lastMatchEnd = lastMatch.index + lastMatch[0].length
|
||||
|
||||
if (endsWithSeparator || rawCursorPos > lastMatchEnd) {
|
||||
// Cursor is after the last word (in whitespace), so it's at start of next word
|
||||
return { wordIndex: Math.min(wordMatches.length, 2), charIndex: 0 }
|
||||
}
|
||||
|
||||
// Cursor is within the last word
|
||||
const charIndex = rawCursorPos - lastMatch.index
|
||||
return { wordIndex: wordMatches.length - 1, charIndex: charIndex }
|
||||
}
|
||||
|
||||
// Compute display words for slot-machine overlay (always 3 slots)
|
||||
const displayWords = computed(() => {
|
||||
const words = getWords(code.value)
|
||||
const result = []
|
||||
|
||||
// Get analysis for validation
|
||||
const { segments } = analyzeWords(code.value)
|
||||
const wordSegments = segments.filter(s => s.isWord)
|
||||
|
||||
// Get current partial word and autocomplete hint
|
||||
const partialWord = getCurrentPartialWord(code.value)
|
||||
const hint = autocompleteHint.value
|
||||
const endsWithSeparator = /[.\s]$/.test(code.value)
|
||||
|
||||
// Calculate where cursor should be displayed
|
||||
const cursor = calcDisplayCursor(code.value, cursorPos.value)
|
||||
|
||||
// Always show exactly 3 slots
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const isCursorSlot = cursor.wordIndex === i
|
||||
|
||||
if (i < words.length) {
|
||||
const word = words[i].toLowerCase()
|
||||
const isInvalid = wordSegments[i]?.invalid || false
|
||||
const isLastWord = i === words.length - 1
|
||||
|
||||
if (isLastWord && !endsWithSeparator && hint && partialWord) {
|
||||
// Show typed prefix + hint suffix in the same slot
|
||||
// Total visible length is the full hint word
|
||||
const totalLen = hint.length
|
||||
result.push({
|
||||
text: '',
|
||||
typedPrefix: partialWord,
|
||||
hintSuffix: hint.slice(partialWord.length),
|
||||
invalid: isInvalid,
|
||||
hasCursor: isCursorSlot,
|
||||
cursorCharIndex: isCursorSlot ? cursor.charIndex : -1,
|
||||
wordLen: totalLen
|
||||
})
|
||||
} else {
|
||||
// Complete word - show cursor at appropriate position
|
||||
result.push({
|
||||
text: word,
|
||||
invalid: isInvalid,
|
||||
hasCursor: isCursorSlot,
|
||||
cursorCharIndex: isCursorSlot ? cursor.charIndex : -1,
|
||||
wordLen: word.length
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Empty slot
|
||||
result.push({
|
||||
text: '',
|
||||
invalid: false,
|
||||
hasCursor: isCursorSlot,
|
||||
cursorCharIndex: 0,
|
||||
wordLen: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const hasThreeValidWords = computed(() => {
|
||||
const words = getWords(code.value)
|
||||
return words.length === 3 && words.every(w => isValidWord(w))
|
||||
})
|
||||
|
||||
function normalizeCode(input) {
|
||||
return input.trim().toLowerCase().split(/[.\s]+/).filter(w => w).join('.')
|
||||
}
|
||||
|
||||
function startPowSolving() {
|
||||
if (!currentChallenge || powPromise) return
|
||||
const challenge = b64dec(currentChallenge)
|
||||
powPromise = solvePoW(challenge, currentWork).then(solution => {
|
||||
powSolution = solution
|
||||
powPromise = null
|
||||
})
|
||||
}
|
||||
|
||||
async function getPowSolution() {
|
||||
if (powSolution) { const s = powSolution; powSolution = null; return s }
|
||||
if (powPromise) { await powPromise; const s = powSolution; powSolution = null; return s }
|
||||
if (!currentChallenge) throw new Error('No PoW challenge available')
|
||||
const challenge = b64dec(currentChallenge)
|
||||
return await solvePoW(challenge, currentWork)
|
||||
}
|
||||
|
||||
function updateChallenge(pow) {
|
||||
if (pow?.challenge) {
|
||||
currentChallenge = pow.challenge
|
||||
currentWork = pow.work
|
||||
powSolution = null
|
||||
powPromise = null
|
||||
startPowSolving()
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureConnection() {
|
||||
if (ws || wsConnecting) return
|
||||
wsConnecting = true
|
||||
try {
|
||||
const authHost = settings.value?.auth_host
|
||||
const wsPath = '/auth/ws/remote-auth/pair'
|
||||
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
|
||||
ws = await aWebSocket(wsUrl)
|
||||
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)
|
||||
ws = null
|
||||
throw err
|
||||
} finally {
|
||||
wsConnecting = false
|
||||
}
|
||||
}
|
||||
|
||||
// Defer cursor position update to after browser processes the key
|
||||
function deferUpdateCursor(event) {
|
||||
// Handle Tab/Space for autocomplete immediately
|
||||
if (event.key === 'Tab' || event.key === ' ') {
|
||||
handleKeydown(event)
|
||||
return
|
||||
}
|
||||
// Defer cursor update to next tick
|
||||
setTimeout(updateCursorPos, 0)
|
||||
}
|
||||
|
||||
// Update cursor position from input
|
||||
function updateCursorPos() {
|
||||
cursorPos.value = inputRef.value?.selectionStart ?? code.value.length
|
||||
}
|
||||
|
||||
function updateAutocomplete() {
|
||||
cursorPos.value = inputRef.value?.selectionStart ?? code.value.length
|
||||
const { word, end } = getWordAtCursor(code.value, cursorPos.value)
|
||||
const completeWordCount = countCompleteWords(code.value)
|
||||
if (completeWordCount >= 3 || !word || word.length < 1 || cursorPos.value !== end) {
|
||||
autocompleteHint.value = ''
|
||||
return
|
||||
}
|
||||
const match = getUniqueMatch(word.toLowerCase())
|
||||
if (match && match !== word.toLowerCase()) autocompleteHint.value = match
|
||||
else autocompleteHint.value = ''
|
||||
}
|
||||
|
||||
function applyAutocomplete() {
|
||||
if (!autocompleteHint.value) return false
|
||||
const { word, start, end } = getWordAtCursor(code.value, cursorPos.value)
|
||||
if (!word) return false
|
||||
const before = code.value.slice(0, start)
|
||||
const wordsBefore = getWords(before).length
|
||||
const isThirdWord = wordsBefore === 2
|
||||
const suffix = isThirdWord ? '' : ' '
|
||||
const after = code.value.slice(end)
|
||||
code.value = before + autocompleteHint.value + suffix + after.trimStart()
|
||||
const newPos = start + autocompleteHint.value.length + suffix.length
|
||||
nextTick(() => {
|
||||
inputRef.value?.setSelectionRange(newPos, newPos)
|
||||
cursorPos.value = newPos
|
||||
})
|
||||
autocompleteHint.value = ''
|
||||
return true
|
||||
}
|
||||
|
||||
// Try to split concatenated words (e.g., "alienalien" -> "alien alien")
|
||||
function trySplitWords(input) {
|
||||
// Only process if there's a continuous string of letters at the end
|
||||
const match = input.match(/^(.*?)([a-zA-Z]+)$/)
|
||||
if (!match) return input
|
||||
|
||||
const prefix = match[1] // Everything before the letter sequence
|
||||
const letters = match[2].toLowerCase()
|
||||
|
||||
// Try to find valid word boundaries in the letter sequence
|
||||
const foundWords = []
|
||||
let remaining = letters
|
||||
|
||||
while (remaining.length > 0) {
|
||||
let foundWord = null
|
||||
|
||||
// Try to find the longest valid word from the start
|
||||
for (let len = Math.min(remaining.length, 6); len >= 3; len--) {
|
||||
const candidate = remaining.slice(0, len)
|
||||
if (isValidWord(candidate)) {
|
||||
foundWord = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (foundWord) {
|
||||
foundWords.push(foundWord)
|
||||
remaining = remaining.slice(foundWord.length)
|
||||
|
||||
// Stop after 3 words
|
||||
if (foundWords.length >= 3) {
|
||||
remaining = ''
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// No valid word found, keep the remaining as-is
|
||||
foundWords.push(remaining)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Only return split version if we found at least one complete word
|
||||
// and there's a clear boundary (more than one segment, or the segment is a complete word)
|
||||
if (foundWords.length > 1 || (foundWords.length === 1 && isValidWord(foundWords[0]) && remaining === '')) {
|
||||
return prefix + foundWords.join(' ')
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
// Immediately update cursor position
|
||||
cursorPos.value = inputRef.value?.selectionStart ?? code.value.length
|
||||
|
||||
// First, try to auto-split concatenated words
|
||||
const splitCode = trySplitWords(code.value)
|
||||
if (splitCode !== code.value) {
|
||||
code.value = splitCode
|
||||
nextTick(() => {
|
||||
const newLen = splitCode.length
|
||||
inputRef.value?.setSelectionRange(newLen, newLen)
|
||||
cursorPos.value = newLen
|
||||
})
|
||||
}
|
||||
|
||||
const words = getWords(code.value)
|
||||
if (words.length >= 3) {
|
||||
const normalized = words.slice(0, 3).join(' ')
|
||||
if (code.value !== normalized) {
|
||||
const cursorWasAtEnd = cursorPos.value >= code.value.length
|
||||
code.value = normalized
|
||||
if (cursorWasAtEnd) {
|
||||
nextTick(() => {
|
||||
inputRef.value?.setSelectionRange(normalized.length, normalized.length)
|
||||
cursorPos.value = normalized.length
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateAutocomplete()
|
||||
if (lookupTimeout) { clearTimeout(lookupTimeout); lookupTimeout = null }
|
||||
deviceInfo.value = null
|
||||
error.value = null
|
||||
serverError.value = false
|
||||
hasInvalidWord.value = !checkWordsValidity(code.value)
|
||||
const currentWords = getWords(code.value)
|
||||
if (currentWords.length >= 1 && !ws && !wsConnecting) ensureConnection()
|
||||
if (currentWords.length === 3) {
|
||||
if (!allWordsValid(code.value)) return
|
||||
lookupTimeout = setTimeout(() => { lookupDeviceInfo() }, 150)
|
||||
}
|
||||
}
|
||||
|
||||
async function lookupDeviceInfo() {
|
||||
if (isProcessing.value || loading.value) return
|
||||
if (!hasThreeValidWords.value) return
|
||||
const normalizedCode = normalizeCode(code.value)
|
||||
if (normalizedCode === lastLookedUpCode && deviceInfo.value) return
|
||||
|
||||
isProcessing.value = true
|
||||
processingStatus.value = 'pow'
|
||||
error.value = null
|
||||
serverError.value = false
|
||||
|
||||
try {
|
||||
await ensureConnection()
|
||||
if (!ws) throw new Error('Failed to connect')
|
||||
const solution = await getPowSolution()
|
||||
const powB64 = b64enc(solution)
|
||||
const currentCode = normalizeCode(code.value)
|
||||
if (!hasThreeValidWords.value) return
|
||||
processingStatus.value = 'server'
|
||||
ws.send_json({ code: currentCode, pow: powB64 })
|
||||
const res = await ws.receive_json()
|
||||
updateChallenge(res.pow)
|
||||
if (typeof res.status === 'number' && res.status >= 400) {
|
||||
error.value = res.detail || 'Request failed'
|
||||
serverError.value = true
|
||||
deviceInfo.value = null
|
||||
lastLookedUpCode = null
|
||||
return
|
||||
}
|
||||
if (res.status === 'found' && res.host) {
|
||||
code.value = currentCode.replace(/\./g, ' ')
|
||||
deviceInfo.value = {
|
||||
host: res.host,
|
||||
user_agent_pretty: res.user_agent_pretty,
|
||||
client_ip: res.client_ip,
|
||||
action: res.action || 'login'
|
||||
}
|
||||
lastLookedUpCode = currentCode
|
||||
nextTick(() => { submitBtnRef.value?.focus() })
|
||||
} else {
|
||||
error.value = 'Unexpected response from server'
|
||||
serverError.value = true
|
||||
deviceInfo.value = null
|
||||
lastLookedUpCode = null
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Lookup error:', err)
|
||||
error.value = err.message || 'Lookup failed'
|
||||
serverError.value = true
|
||||
deviceInfo.value = null
|
||||
lastLookedUpCode = null
|
||||
if (ws) { ws.close(); ws = null }
|
||||
} finally {
|
||||
isProcessing.value = false
|
||||
processingStatus.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (event.key === 'Tab') {
|
||||
if (autocompleteHint.value) {
|
||||
const applied = applyAutocomplete()
|
||||
if (applied) { event.preventDefault(); handleInput(); return }
|
||||
}
|
||||
if (code.value.trim()) event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (event.key === ' ' && autocompleteHint.value) {
|
||||
const applied = applyAutocomplete()
|
||||
if (applied) { event.preventDefault(); handleInput() }
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCode() {
|
||||
if (!deviceInfo.value || loading.value) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
if (!ws) await ensureConnection()
|
||||
if (!ws) throw new Error('Failed to connect')
|
||||
const solution = await getPowSolution()
|
||||
const powB64 = b64enc(solution)
|
||||
ws.send_json({ authenticate: true, pow: powB64 })
|
||||
const res = await ws.receive_json()
|
||||
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')
|
||||
const authResponse = await startAuthentication(res.optionsJSON)
|
||||
ws.send_json(authResponse)
|
||||
const result = await ws.receive_json()
|
||||
if (typeof result.status === 'number' && result.status >= 400) throw new Error(result.detail || 'Authentication failed')
|
||||
if (result.status === 'success') {
|
||||
showMessage('Device authenticated successfully!', 'success', 3000)
|
||||
emit('completed')
|
||||
reset()
|
||||
} else {
|
||||
throw new Error(result.detail || 'Authentication failed')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Pairing error:', err)
|
||||
const message = err.name === 'NotAllowedError'
|
||||
? 'Passkey authentication was cancelled'
|
||||
: (err.message || 'Authentication failed')
|
||||
error.value = message
|
||||
// Don't show toast - error is shown in dialog
|
||||
emit('error', message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (ws) { ws.close(); ws = null }
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticateWithToken() {
|
||||
if (!props.token || loading.value) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const authHost = settings.value?.auth_host
|
||||
const wsPath = `/auth/ws/remote-auth/pair`
|
||||
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
|
||||
|
||||
ws = await aWebSocket(wsUrl)
|
||||
|
||||
// Receive PoW challenge
|
||||
const powChallenge = await ws.receive_json()
|
||||
if (powChallenge.pow) {
|
||||
const challenge = b64dec(powChallenge.pow.challenge)
|
||||
const nonces = await solvePoW(challenge, powChallenge.pow.work)
|
||||
// Send the 5-word token instead of 3-word code
|
||||
ws.send_json({ code: props.token, pow: b64enc(nonces) })
|
||||
}
|
||||
|
||||
// Receive device info
|
||||
const deviceRes = await ws.receive_json()
|
||||
if (typeof deviceRes.status === 'number' && deviceRes.status >= 400) {
|
||||
throw new Error(deviceRes.detail || 'This link is no longer valid')
|
||||
}
|
||||
|
||||
if (deviceRes.status !== 'found') {
|
||||
throw new Error('This link is no longer valid')
|
||||
}
|
||||
|
||||
// Now authenticate
|
||||
const solution = await solvePoW(b64dec(deviceRes.pow.challenge), deviceRes.pow.work)
|
||||
ws.send_json({ authenticate: true, pow: b64enc(solution) })
|
||||
|
||||
const res = await ws.receive_json()
|
||||
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')
|
||||
|
||||
const authResponse = await startAuthentication(res.optionsJSON)
|
||||
ws.send_json(authResponse)
|
||||
|
||||
const result = await ws.receive_json()
|
||||
if (typeof result.status === 'number' && result.status >= 400) throw new Error(result.detail || 'Authentication failed')
|
||||
if (result.status === 'success') {
|
||||
showMessage('Device authenticated successfully!', 'success', 3000)
|
||||
emit('completed')
|
||||
reset()
|
||||
} else {
|
||||
throw new Error(result.detail || 'Authentication failed')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Token authentication error:', err)
|
||||
const message = err.name === 'NotAllowedError'
|
||||
? 'Passkey authentication was cancelled'
|
||||
: (err.message || 'Authentication failed')
|
||||
error.value = message
|
||||
emit('error', message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (ws) { ws.close(); ws = null }
|
||||
}
|
||||
}
|
||||
|
||||
async function deny() {
|
||||
// Send deny message to server before closing websocket
|
||||
if (ws) {
|
||||
try {
|
||||
ws.send_json({ deny: true })
|
||||
// Give the server a moment to process the denial
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
} catch (e) {
|
||||
console.error('Error sending deny message:', e)
|
||||
}
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
|
||||
// In token mode (standalone link), try to close the window
|
||||
if (props.token) {
|
||||
try {
|
||||
window.close()
|
||||
} catch (e) {
|
||||
// If we can't close the window, just reset
|
||||
reset()
|
||||
}
|
||||
} else {
|
||||
// In pairing mode, just reset to initial state
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
code.value = ''
|
||||
error.value = null
|
||||
serverError.value = false
|
||||
deviceInfo.value = null
|
||||
isProcessing.value = false
|
||||
processingStatus.value = ''
|
||||
autocompleteHint.value = ''
|
||||
hasInvalidWord.value = false
|
||||
lastLookedUpCode = null
|
||||
if (ws) { ws.close(); ws = null }
|
||||
currentChallenge = null
|
||||
currentWork = null
|
||||
powPromise = null
|
||||
powSolution = null
|
||||
}
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchSettings()
|
||||
inputRef.value?.focus()
|
||||
// Initialize cursor position
|
||||
nextTick(() => {
|
||||
cursorPos.value = inputRef.value?.selectionStart ?? 0
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (lookupTimeout) { clearTimeout(lookupTimeout); lookupTimeout = null }
|
||||
if (ws) { ws.close(); ws = null }
|
||||
})
|
||||
|
||||
defineExpose({ reset, deny, code, handleInput, authenticateWithToken, loading, error })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Input Mode Styles */
|
||||
.pairing-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
width: 280px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Slot machine visual display (matches RemoteAuthInline) */
|
||||
.slot-machine {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--color-surface-hover, rgba(0, 0, 0, 0.03));
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
display: flex;
|
||||
gap: 0;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.slot-machine.has-error {
|
||||
border-color: var(--color-error, #ef4444);
|
||||
background: var(--color-error-bg, rgba(239, 68, 68, 0.05));
|
||||
}
|
||||
|
||||
.slot-machine.is-complete {
|
||||
border-color: var(--color-success, #10b981);
|
||||
}
|
||||
|
||||
.slot-reel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1 1 33.333%;
|
||||
min-width: 0;
|
||||
height: 1.8em;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.slot-reel:not(:last-child) {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.slot-word {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.slot-word .typed-prefix {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.slot-word .hint-suffix {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.cursor-overlay {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 1.2em;
|
||||
background: var(--color-text);
|
||||
animation: none;
|
||||
pointer-events: none;
|
||||
/* Position based on character index - calculate from center of slot */
|
||||
left: calc(50% + (var(--cursor-pos) - var(--word-len, 0) / 2) * 0.65em);
|
||||
transform: translateX(-1px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.input-wrapper.focused .cursor-overlay {
|
||||
opacity: 1;
|
||||
animation: cursorBlink 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes cursorBlink {
|
||||
0%, 49% {
|
||||
opacity: 1;
|
||||
}
|
||||
50%, 100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.slot-reel.invalid-word .slot-word {
|
||||
color: var(--color-error, #ef4444);
|
||||
}
|
||||
|
||||
.slot-reel.invalid-word .slot-word .typed-prefix {
|
||||
color: var(--color-error, #ef4444);
|
||||
}
|
||||
|
||||
.slot-reel.invalid-word .cursor-overlay {
|
||||
background: var(--color-error, #ef4444);
|
||||
}
|
||||
|
||||
.slot-reel.empty .slot-word {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Hidden input - keeps focus and handles keyboard input */
|
||||
.pairing-input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
caret-color: transparent;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.pairing-input.hidden-input {
|
||||
color: transparent;
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.pairing-input:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pairing-input::placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.device-permit-text {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.device-meta {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-error, #ef4444);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,692 @@
|
||||
<template>
|
||||
<div class="remote-auth-inline">
|
||||
<!-- Success state -->
|
||||
<div v-if="completed" class="success-section">
|
||||
<p class="success-message">✅ {{ successMessage }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="error-section">
|
||||
<p class="error-message">{{ error }}</p>
|
||||
<button class="btn-primary" @click="retry" style="margin-top: 0.75rem;">Try Again</button>
|
||||
</div>
|
||||
|
||||
<!-- Connecting phase -->
|
||||
<div v-else-if="phase === 'connecting'" class="auth-display">
|
||||
<div class="auth-content">
|
||||
<div class="pairing-code-section">
|
||||
<p class="pairing-label">Enter the code words:</p>
|
||||
<div class="slot-machine" aria-hidden="true">
|
||||
<div class="slot-reel" v-for="(word, index) in animatedWords" :key="index">
|
||||
<div class="slot-word">{{ word }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="site-url">{{ siteUrlDisplay }}</p>
|
||||
</div>
|
||||
|
||||
<div class="qr-section">
|
||||
<div class="qr-code qr-placeholder"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="waiting-indicator">
|
||||
<div class="spinner-small"></div>
|
||||
<span>Generating secure link…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Waiting/Authenticating phase - show codes and QR -->
|
||||
<div v-else class="auth-display">
|
||||
<div class="auth-content">
|
||||
<div v-if="pairingCode" class="pairing-code-section">
|
||||
<p class="pairing-label">Enter the code words:</p>
|
||||
<div class="slot-machine stopped">
|
||||
<div class="slot-reel" v-for="(word, index) in displayCode.split(' ')" :key="index">
|
||||
<div class="slot-word">{{ word }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="site-url">{{ siteUrlDisplay }}</p>
|
||||
</div>
|
||||
|
||||
<div class="qr-section">
|
||||
<a :href="url" @click.prevent="copyLink" class="qr-link" title="Click to copy link">
|
||||
<canvas ref="qrCanvas" class="qr-code"></canvas>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showCopyToast" class="copy-toast">
|
||||
✓ Link copied to clipboard
|
||||
</div>
|
||||
|
||||
<div class="waiting-indicator">
|
||||
<div class="spinner-small"></div>
|
||||
<span>{{ waitingMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import QRCode from 'qrcode/lib/browser'
|
||||
import aWebSocket from '@/utils/awaitable-websocket'
|
||||
import { dec as b64dec, enc as b64enc } from '@/utils/base64url'
|
||||
import { getSettings } from '@/utils/settings'
|
||||
import { solvePoW } from '@/utils/pow'
|
||||
import { words } from '@/utils/wordlist'
|
||||
|
||||
const props = defineProps({
|
||||
active: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['authenticated', 'cancelled', 'error', 'register'])
|
||||
|
||||
const url = ref(null)
|
||||
const pairingCode = ref(null)
|
||||
const expires = ref(null)
|
||||
const qrCanvas = ref(null)
|
||||
const completed = ref(false)
|
||||
const error = ref(null)
|
||||
const phase = ref('connecting')
|
||||
const settings = ref(null)
|
||||
const showCopyToast = ref(false)
|
||||
const animatedWords = ref(['', '', ''])
|
||||
let ws = null
|
||||
let copyToastTimer = null
|
||||
let wordAnimationTimer = null
|
||||
|
||||
const displayCode = computed(() => pairingCode.value ? pairingCode.value.replace(/\./g, ' ') : '')
|
||||
|
||||
const siteUrlDisplay = computed(() => {
|
||||
if (!settings.value) return ''
|
||||
const authSiteUrl = settings.value.auth_site_url || `${location.protocol}//${location.host}/auth/`
|
||||
// Remove the protocol and any trailing slash
|
||||
const withoutProtocol = authSiteUrl.replace(/^https?:\/\//, '')
|
||||
return withoutProtocol.endsWith('/') ? withoutProtocol.slice(0, -1) : withoutProtocol
|
||||
})
|
||||
|
||||
const waitingMessage = computed(() => {
|
||||
return phase.value === 'authenticating'
|
||||
? 'Complete on another device…'
|
||||
: 'Waiting for authentication…'
|
||||
})
|
||||
|
||||
const successMessage = computed(() => 'Authenticated successfully!')
|
||||
|
||||
function getRandomWord() {
|
||||
return words[Math.floor(Math.random() * words.length)]
|
||||
}
|
||||
|
||||
function startWordAnimation() {
|
||||
// Initialize with random words
|
||||
animatedWords.value = [getRandomWord(), getRandomWord(), getRandomWord()]
|
||||
|
||||
let updateCount = 0
|
||||
const maxUpdates = 20 // Number of cycles before stopping
|
||||
|
||||
// Different intervals for each slot to spin independently
|
||||
const intervals = [
|
||||
setInterval(() => {
|
||||
const newWords = [...animatedWords.value]
|
||||
newWords[0] = getRandomWord()
|
||||
animatedWords.value = newWords
|
||||
}, 140),
|
||||
setInterval(() => {
|
||||
const newWords = [...animatedWords.value]
|
||||
newWords[1] = getRandomWord()
|
||||
animatedWords.value = newWords
|
||||
}, 170),
|
||||
setInterval(() => {
|
||||
const newWords = [...animatedWords.value]
|
||||
newWords[2] = getRandomWord()
|
||||
animatedWords.value = newWords
|
||||
}, 200)
|
||||
]
|
||||
|
||||
wordAnimationTimer = intervals
|
||||
|
||||
// Stop all after max updates
|
||||
setTimeout(() => {
|
||||
intervals.forEach(interval => clearInterval(interval))
|
||||
wordAnimationTimer = null
|
||||
}, maxUpdates * 170) // Average interval time
|
||||
}
|
||||
|
||||
function stopWordAnimation() {
|
||||
if (wordAnimationTimer) {
|
||||
if (Array.isArray(wordAnimationTimer)) {
|
||||
wordAnimationTimer.forEach(interval => clearInterval(interval))
|
||||
} else {
|
||||
clearInterval(wordAnimationTimer)
|
||||
}
|
||||
wordAnimationTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function startRemoteAuth() {
|
||||
error.value = null
|
||||
completed.value = false
|
||||
url.value = null
|
||||
pairingCode.value = null
|
||||
expires.value = null
|
||||
phase.value = 'connecting'
|
||||
|
||||
// Start word animation
|
||||
startWordAnimation()
|
||||
|
||||
try {
|
||||
settings.value = await getSettings()
|
||||
const authHost = settings.value?.auth_host
|
||||
const wsPath = '/auth/ws/remote-auth/request'
|
||||
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
|
||||
|
||||
ws = await aWebSocket(wsUrl)
|
||||
|
||||
// PoW challenge
|
||||
const powChallenge = await ws.receive_json()
|
||||
if (powChallenge.pow) {
|
||||
const challenge = b64dec(powChallenge.pow.challenge)
|
||||
const nonces = await solvePoW(challenge, powChallenge.pow.work)
|
||||
ws.send_json({ pow: b64enc(nonces), action: 'login' })
|
||||
}
|
||||
|
||||
// Receive the remote auth token and pairing code
|
||||
const res = await ws.receive_json()
|
||||
|
||||
if (res.status) {
|
||||
throw new Error(res.detail || `Failed to create remote auth request: ${res.status}`)
|
||||
}
|
||||
|
||||
// Build the URL in frontend using auth_site_url from settings
|
||||
const authSiteUrl = settings.value?.auth_site_url || `${location.protocol}//${location.host}/auth/`
|
||||
url.value = authSiteUrl + res.token
|
||||
pairingCode.value = res.pairing_code
|
||||
expires.value = res.expires
|
||||
|
||||
// Stop word animation
|
||||
stopWordAnimation()
|
||||
|
||||
phase.value = 'waiting'
|
||||
|
||||
await nextTick()
|
||||
drawQR()
|
||||
|
||||
// Wait for authentication
|
||||
while (true) {
|
||||
const msg = await ws.receive_json()
|
||||
|
||||
if (msg.status === 'locked') {
|
||||
// Someone has entered the code and is authenticating
|
||||
phase.value = 'authenticating'
|
||||
} else if (msg.status === 'paired') {
|
||||
// Legacy/compatibility: Device paired, now authenticating
|
||||
phase.value = 'authenticating'
|
||||
} else if (msg.status === 'authenticated') {
|
||||
// Success
|
||||
completed.value = true
|
||||
emit('authenticated', { session_token: msg.session_token })
|
||||
break
|
||||
} else if (msg.status === 'denied') {
|
||||
// Explicitly denied by the authenticating device
|
||||
throw new Error('Access denied')
|
||||
} else if (msg.status === 'completed') {
|
||||
// Registration flow
|
||||
if (msg.reset_token) {
|
||||
completed.value = true
|
||||
emit('register', msg.reset_token)
|
||||
}
|
||||
break
|
||||
} else if (msg.status === 'error' || msg.detail) {
|
||||
throw new Error(msg.detail || 'Remote authentication failed')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Remote authentication error:', err)
|
||||
const message = err.message || 'Authentication failed'
|
||||
error.value = message
|
||||
emit('error', message)
|
||||
} finally {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawQR() {
|
||||
if (!url.value || !qrCanvas.value) return
|
||||
|
||||
// Use a fixed scale that works well for most URLs
|
||||
// This allows CSS to control the actual display size
|
||||
const scale = 6
|
||||
|
||||
QRCode.toCanvas(qrCanvas.value, url.value, {
|
||||
scale: scale,
|
||||
margin: 0,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
}
|
||||
}, err => {
|
||||
if (err) console.error('QR code error:', err)
|
||||
})
|
||||
qrCanvas.value.removeAttribute('style')
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
if (!url.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(url.value)
|
||||
showCopyToast.value = true
|
||||
if (copyToastTimer) clearTimeout(copyToastTimer)
|
||||
copyToastTimer = setTimeout(() => {
|
||||
showCopyToast.value = false
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy link:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function retry() {
|
||||
startRemoteAuth()
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
emit('cancelled')
|
||||
}
|
||||
|
||||
watch(() => props.active, (newVal) => {
|
||||
if (newVal && !url.value && !error.value && !completed.value) {
|
||||
startRemoteAuth()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.active) {
|
||||
startRemoteAuth()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
if (copyToastTimer) {
|
||||
clearTimeout(copyToastTimer)
|
||||
}
|
||||
stopWordAnimation()
|
||||
})
|
||||
|
||||
defineExpose({ retry, cancel })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.remote-auth-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loading-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem 1rem;
|
||||
min-height: 180px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-section p {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px 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); }
|
||||
}
|
||||
|
||||
.auth-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.auth-content {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.loading-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.loading-placeholder p {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.pairing-code-section {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 280px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.pairing-label {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.slot-machine {
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--color-surface-hover, rgba(0, 0, 0, 0.03));
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slot-reel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 1.8em;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: var(--color-surface, rgba(255, 255, 255, 0.5));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.slot-machine:not(.stopped) .slot-reel:nth-child(1) {
|
||||
animation: slotSpin 0.14s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.slot-machine:not(.stopped) .slot-reel:nth-child(2) {
|
||||
animation: slotSpin 0.17s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.slot-machine:not(.stopped) .slot-reel:nth-child(3) {
|
||||
animation: slotSpin 0.20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.slot-word {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slot-machine:not(.stopped) .slot-reel:nth-child(1) .slot-word {
|
||||
animation: wordRoll 0.14s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.slot-machine:not(.stopped) .slot-reel:nth-child(2) .slot-word {
|
||||
animation: wordRoll 0.17s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.slot-machine:not(.stopped) .slot-reel:nth-child(3) .slot-word {
|
||||
animation: wordRoll 0.20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes slotSpin {
|
||||
0% {
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: inset 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
100% {
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wordRoll {
|
||||
0% {
|
||||
transform: translateY(-30%) scale(0.9);
|
||||
opacity: 0.4;
|
||||
filter: blur(1.5px);
|
||||
}
|
||||
25% {
|
||||
transform: translateY(-10%) scale(0.95);
|
||||
opacity: 0.6;
|
||||
filter: blur(1px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
75% {
|
||||
transform: translateY(10%) scale(0.95);
|
||||
opacity: 0.6;
|
||||
filter: blur(1px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(30%) scale(0.9);
|
||||
opacity: 0.4;
|
||||
filter: blur(1.5px);
|
||||
}
|
||||
}
|
||||
|
||||
.site-url {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.qr-section {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.qr-label {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.qr-link {
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
width: 100%;
|
||||
max-width: 180px;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
overflow: hidden;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.qr-link:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.qr-link:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.qr-placeholder {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: qrPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes qrPulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.7; }
|
||||
}.waiting-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--color-surface-hover, rgba(0, 0, 0, 0.02));
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.spinner-small {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.success-section {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
min-height: 180px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--color-success, #10b981);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.error-section {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-error, #ef4444);
|
||||
}
|
||||
|
||||
.copy-toast {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--color-success, #10b981);
|
||||
color: white;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(1rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 640px) {
|
||||
.auth-content {
|
||||
gap: 1.5rem;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pairing-code-section,
|
||||
.qr-section {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.qr-section {
|
||||
max-width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.pairing-code {
|
||||
font-size: 1.1rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
}
|
||||
|
||||
.pairing-code-section {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -11,11 +11,13 @@
|
||||
<header class="view-header center">
|
||||
<h1>{{ headingTitle }}</h1>
|
||||
<p v-if="isAuthenticated" class="user-line">👤 {{ userDisplayName }}</p>
|
||||
<p class="view-lede">{{ headerMessage }}</p>
|
||||
<p class="view-lede" v-html="headerMessage"></p>
|
||||
</header>
|
||||
|
||||
<section class="section-block">
|
||||
<div class="section-body center">
|
||||
<!-- Local passkey authentication view -->
|
||||
<div v-if="authView === 'local'" class="auth-view">
|
||||
<div class="button-row center">
|
||||
<slot name="actions"
|
||||
:loading="loading"
|
||||
@@ -23,48 +25,74 @@
|
||||
:is-authenticated="isAuthenticated"
|
||||
:authenticate="authenticateUser"
|
||||
:logout="logoutUser"
|
||||
:mode="mode"
|
||||
:start-remote-auth="startRemoteAuth">
|
||||
:mode="mode">
|
||||
<!-- Default actions -->
|
||||
<button class="btn-secondary" :disabled="loading" @click="$emit('back')">Back</button>
|
||||
<button v-if="canAuthenticate" class="btn-primary" :disabled="loading" @click="authenticateUser">
|
||||
{{ loading ? (mode === 'reauth' ? 'Verifying…' : 'Signing in…') : (mode === 'reauth' ? 'Verify' : 'Login') }}
|
||||
</button>
|
||||
<button v-if="canAuthenticate && mode !== 'reauth'" class="btn-secondary" :disabled="loading" @click="startRemoteAuth">
|
||||
Use Another Device
|
||||
</button>
|
||||
<button v-if="isAuthenticated && mode !== 'reauth'" class="btn-danger" :disabled="loading" @click="logoutUser">Logout</button>
|
||||
<button v-if="isAuthenticated && mode !== 'reauth'" class="btn-primary" :disabled="loading" @click="openProfile">Profile</button>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remote authentication view (request new remote auth) -->
|
||||
<div v-else-if="authView === 'remote'" class="auth-view">
|
||||
<RemoteAuthInline
|
||||
:active="authView === 'remote'"
|
||||
@authenticated="handleRemoteAuthenticated"
|
||||
@register="handleRemoteRegistration"
|
||||
@cancelled="switchToLocal"
|
||||
@error="handleRemoteAuthError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Remote auth completion view (complete auth from link) -->
|
||||
<div v-else-if="authView === 'complete'" class="auth-view">
|
||||
<!-- Hidden RemoteAuth component for logic only -->
|
||||
<RemoteAuth
|
||||
ref="remoteAuthRef"
|
||||
:token="remoteAuthToken"
|
||||
@completed="handleRemoteAuthCompleted"
|
||||
@error="handleRemoteAuthError"
|
||||
@back="switchToLocal"
|
||||
style="display: none;"
|
||||
/>
|
||||
<!-- Show error if any -->
|
||||
<p v-if="remoteAuthRef?.error" class="error-message" style="margin-bottom: 1rem;">{{ remoteAuthRef.error }}</p>
|
||||
<!-- Buttons in dialog style -->
|
||||
<div class="button-row center">
|
||||
<button class="btn-secondary" :disabled="remoteAuthRef?.loading" @click="remoteAuthRef?.deny()">Deny</button>
|
||||
<button class="btn-primary" :disabled="remoteAuthRef?.loading" @click="remoteAuthRef?.authenticateWithToken()">
|
||||
{{ remoteAuthRef?.loading ? 'Authenticating…' : 'Authorize' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Remote Auth Modal -->
|
||||
<RemoteAuthLinkModal
|
||||
:active="showRemoteAuth"
|
||||
@authenticated="handleRemoteAuthenticated"
|
||||
@cancelled="showRemoteAuth = false"
|
||||
@close="showRemoteAuth = false"
|
||||
@error="handleRemoteAuthError"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import passkey from '@/utils/passkey'
|
||||
import { getSettings } from '@/utils/settings'
|
||||
import { getSettings, uiBasePath } from '@/utils/settings'
|
||||
import { fetchJson, getUserFriendlyErrorMessage } from '@/utils/api'
|
||||
import RemoteAuthLinkModal from '@/components/RemoteAuthLinkModal.vue'
|
||||
import RemoteAuthInline from '@/components/RemoteAuthRequest.vue'
|
||||
import RemoteAuth from '@/components/RemoteAuthPermit.vue'
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'login',
|
||||
validator: (value) => ['login', 'reauth', 'forbidden'].includes(value)
|
||||
},
|
||||
remoteAuthToken: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
@@ -76,22 +104,23 @@ const loading = ref(false)
|
||||
const settings = ref(null)
|
||||
const userInfo = ref(null)
|
||||
const currentView = ref('initial') // 'initial', 'login', 'forbidden'
|
||||
const showRemoteAuth = ref(false)
|
||||
const authView = ref('local') // 'local', 'remote', or 'complete'
|
||||
const remoteAuthRef = ref(null)
|
||||
let statusTimer = null
|
||||
|
||||
const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
|
||||
|
||||
const canAuthenticate = computed(() => {
|
||||
if (initializing.value) return false
|
||||
// In reauth mode, allow authentication even if already authenticated
|
||||
if (props.mode === 'reauth') return true
|
||||
// In forbidden view (authenticated but lacking permissions), don't allow authentication
|
||||
if (currentView.value === 'forbidden') return false
|
||||
// In login view or initial state, allow authentication
|
||||
return true
|
||||
})
|
||||
|
||||
const headingTitle = computed(() => {
|
||||
if (authView.value === 'complete') {
|
||||
return `🔐 ${settings.value?.rp_name || location.origin}`
|
||||
}
|
||||
if (props.mode === 'reauth') {
|
||||
return `🔐 Additional Authentication`
|
||||
}
|
||||
@@ -100,12 +129,21 @@ const headingTitle = computed(() => {
|
||||
})
|
||||
|
||||
const headerMessage = computed(() => {
|
||||
if (authView.value === 'complete') {
|
||||
return 'Complete the login request from another device.'
|
||||
}
|
||||
if (props.mode === 'reauth') {
|
||||
return 'Please verify your identity to continue with this action.'
|
||||
}
|
||||
if (currentView.value === 'forbidden') {
|
||||
return 'You lack the required permissions.'
|
||||
}
|
||||
if (authView.value === 'remote') {
|
||||
return 'Confirm from your other device. Or <a href="#" class="inline-link" data-action="local">this device</a>.'
|
||||
}
|
||||
if (canAuthenticate.value && props.mode !== 'reauth') {
|
||||
return 'Please sign in with your passkey. Or use <a href="#" class="inline-link" data-action="remote">another device</a>.'
|
||||
}
|
||||
return 'Please sign in with your passkey.'
|
||||
})
|
||||
|
||||
@@ -137,7 +175,6 @@ async function fetchSettings() {
|
||||
async function fetchUserInfo() {
|
||||
try {
|
||||
userInfo.value = await fetchJson('/auth/api/user-info', { method: 'POST' })
|
||||
// Determine view based on authentication status
|
||||
if (isAuthenticated.value && props.mode !== 'reauth') {
|
||||
currentView.value = 'forbidden'
|
||||
emit('forbidden', userInfo.value)
|
||||
@@ -146,7 +183,6 @@ async function fetchUserInfo() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load user info', error)
|
||||
// For 401/403 just go to login, for other errors show message
|
||||
if (error.status !== 401 && error.status !== 403) {
|
||||
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
|
||||
}
|
||||
@@ -185,7 +221,6 @@ async function logoutUser() {
|
||||
try {
|
||||
await fetchJson('/auth/api/logout', { method: 'POST' })
|
||||
userInfo.value = null
|
||||
// Switch to login view after logout
|
||||
currentView.value = 'login'
|
||||
showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
|
||||
} catch (error) {
|
||||
@@ -196,7 +231,6 @@ async function logoutUser() {
|
||||
}
|
||||
|
||||
function openProfile() {
|
||||
// Open profile in a new window with a specific name to reuse the same tab
|
||||
const profileWindow = window.open('/auth/', 'passkey_auth_profile')
|
||||
if (profileWindow) profileWindow.focus()
|
||||
}
|
||||
@@ -211,13 +245,15 @@ async function setSessionCookie(result) {
|
||||
})
|
||||
}
|
||||
|
||||
// Remote authentication from another device
|
||||
function startRemoteAuth() {
|
||||
showRemoteAuth.value = true
|
||||
function switchToRemote() {
|
||||
authView.value = 'remote'
|
||||
}
|
||||
|
||||
function switchToLocal() {
|
||||
authView.value = 'local'
|
||||
}
|
||||
|
||||
async function handleRemoteAuthenticated(result) {
|
||||
showRemoteAuth.value = false
|
||||
showMessage('Authenticated from another device!', 'success', 2000)
|
||||
try {
|
||||
await setSessionCookie(result)
|
||||
@@ -230,15 +266,51 @@ async function handleRemoteAuthenticated(result) {
|
||||
emit('authenticated', result)
|
||||
}
|
||||
|
||||
function handleRemoteRegistration(token) {
|
||||
showMessage('Registration approved! Redirecting...', 'success', 2000)
|
||||
const basePath = uiBasePath() || '/auth/'
|
||||
window.location.href = `${basePath}${token}`
|
||||
}
|
||||
|
||||
function handleRemoteAuthError(errorMsg) {
|
||||
showRemoteAuth.value = false
|
||||
showMessage(errorMsg || 'Remote authentication failed', 'error', 4000)
|
||||
// Error is already shown in the RemoteAuth component, don't show toast
|
||||
}
|
||||
|
||||
function handleRemoteAuthCompleted() {
|
||||
showMessage('The other device is now logged in!', 'success', 3000)
|
||||
// Switch back to local view after completion
|
||||
authView.value = 'local'
|
||||
}
|
||||
|
||||
function handleHeaderLinkClick(event) {
|
||||
const target = event.target
|
||||
if (target.tagName === 'A' && target.classList.contains('inline-link')) {
|
||||
event.preventDefault()
|
||||
const action = target.dataset.action
|
||||
if (action === 'remote') {
|
||||
switchToRemote()
|
||||
} else if (action === 'local') {
|
||||
switchToLocal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchSettings()
|
||||
await fetchUserInfo()
|
||||
initializing.value = false
|
||||
|
||||
// If we have a remote auth token from the URL, switch to completion mode
|
||||
if (props.remoteAuthToken) {
|
||||
authView.value = 'complete'
|
||||
}
|
||||
|
||||
// Add click handler for inline links
|
||||
document.addEventListener('click', handleHeaderLinkClick)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleHeaderLinkClick)
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
@@ -249,9 +321,8 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.button-row.center { display: flex; justify-content: center; gap: 0.75rem; }
|
||||
.button-row.center { display: flex; justify-content: center; gap: 0.75rem; flex-wrap: wrap; }
|
||||
.user-line { margin: 0.5rem 0 0; font-weight: 500; color: var(--color-text); }
|
||||
/* Vertically center the restricted "dialog" surface in the viewport */
|
||||
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
|
||||
.surface.surface--tight {
|
||||
max-width: 520px;
|
||||
@@ -261,4 +332,24 @@ main.view-root { min-height: 100vh; align-items: center; justify-content: center
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
}
|
||||
|
||||
.auth-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.view-lede :deep(.inline-link) {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
transition: opacity 0.15s;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.view-lede :deep(.inline-link:hover) {
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="userLoaded" class="user-info">
|
||||
<div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }">
|
||||
<h3 class="user-name-heading">
|
||||
<span class="icon">👤</span>
|
||||
<span class="user-name-row">
|
||||
@@ -11,12 +11,15 @@
|
||||
<div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div>
|
||||
<div class="role-line" v-if="roleName">{{ roleName }}</div>
|
||||
</div>
|
||||
<span><strong>Visits:</strong></span>
|
||||
<span>{{ visits || 0 }}</span>
|
||||
<span><strong>Registered:</strong></span>
|
||||
<span>{{ formatDate(createdAt) }}</span>
|
||||
<span><strong>Last seen:</strong></span>
|
||||
<span>{{ formatDate(lastSeen) }}</span>
|
||||
<span class="info-label"><strong>Visits:</strong></span>
|
||||
<span class="info-value">{{ visits || 0 }}</span>
|
||||
<span class="info-label"><strong>Registered:</strong></span>
|
||||
<span class="info-value">{{ formatDate(createdAt) }}</span>
|
||||
<span class="info-label"><strong>Last seen:</strong></span>
|
||||
<span class="info-value">{{ formatDate(lastSeen) }}</span>
|
||||
<div v-if="$slots.default" class="user-info-extra">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -44,13 +47,50 @@ const userLoaded = computed(() => !!props.name)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-info { display: grid; grid-template-columns: auto 1fr; gap: 10px; }
|
||||
.user-info h3 { grid-column: span 2; }
|
||||
.org-role-sub { grid-column: span 2; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; }
|
||||
.user-info.has-extra {
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-areas:
|
||||
"heading heading"
|
||||
"org org"
|
||||
"label1 value1"
|
||||
"label2 value2"
|
||||
"label3 value3"
|
||||
"extra extra";
|
||||
}
|
||||
|
||||
.user-info:not(.has-extra) {
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-areas:
|
||||
"heading heading"
|
||||
"org org"
|
||||
"label1 value1"
|
||||
"label2 value2"
|
||||
"label3 value3";
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.user-info.has-extra {
|
||||
grid-template-columns: auto 1fr 2fr;
|
||||
grid-template-areas:
|
||||
"heading heading extra"
|
||||
"org org extra"
|
||||
"label1 value1 extra"
|
||||
"label2 value2 extra"
|
||||
"label3 value3 extra";
|
||||
}
|
||||
}
|
||||
|
||||
.user-name-heading { grid-area: heading; display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0 0 0.25rem 0; }
|
||||
.org-role-sub { grid-area: org; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; }
|
||||
.org-line { font-size: .7rem; font-weight:600; line-height:1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.role-line { font-size:.65rem; color: var(--color-text-muted); line-height:1.1; }
|
||||
.user-info span { text-align: left; }
|
||||
.user-name-heading { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0 0 0.25rem 0; }
|
||||
.info-label:nth-of-type(1) { grid-area: label1; }
|
||||
.info-value:nth-of-type(2) { grid-area: value1; }
|
||||
.info-label:nth-of-type(3) { grid-area: label2; }
|
||||
.info-value:nth-of-type(4) { grid-area: value2; }
|
||||
.info-label:nth-of-type(5) { grid-area: label3; }
|
||||
.info-value:nth-of-type(6) { grid-area: value3; }
|
||||
.user-info-extra { grid-area: extra; padding-left: 2rem; border-left: 1px solid var(--color-border); }
|
||||
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; }
|
||||
.user-name-row.editing { flex: 1 1 auto; }
|
||||
.icon { flex: 0 0 auto; }
|
||||
@@ -62,5 +102,6 @@ const userLoaded = computed(() => !!props.name)
|
||||
.mini-btn:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); }
|
||||
.mini-btn:active:not(:disabled) { transform: translateY(1px); }
|
||||
.mini-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
@media (max-width: 768px) { .user-info-extra { padding-left: 0; padding-top: 1rem; border-left: none; border-top: 1px solid var(--color-border); } }
|
||||
@media (max-width: 480px) { .user-name-heading { flex-direction: column; align-items: flex-start; } .user-name-row.editing { width: 100%; } .display-name { max-width: 100%; } }
|
||||
</style>
|
||||
|
||||
@@ -18,12 +18,36 @@ class AwaitableWebSocket extends WebSocket {
|
||||
}
|
||||
this.onclose = e => {
|
||||
if (!this.#opened) {
|
||||
reject(new Error(`WebSocket ${this.url} failed to connect, code ${e.code}`))
|
||||
reject(new Error(`Failed to connect to server (code ${e.code})`))
|
||||
return
|
||||
}
|
||||
this.#err = e.wasClean
|
||||
? new Error(`Websocket ${this.url} closed ${e.code}`)
|
||||
: new Error(`WebSocket ${this.url} closed with error ${e.code}`)
|
||||
// Create user-friendly close messages
|
||||
let message
|
||||
if (e.wasClean) {
|
||||
// Standard close codes
|
||||
switch (e.code) {
|
||||
case 1000: message = 'Connection closed normally'; break
|
||||
case 1001: message = 'Server is going away'; break
|
||||
case 1002: message = 'Protocol error'; break
|
||||
case 1003: message = 'Unsupported data received'; break
|
||||
case 1006: message = 'Connection lost unexpectedly'; break
|
||||
case 1007: message = 'Invalid data received'; break
|
||||
case 1008: message = 'Policy violation'; break
|
||||
case 1009: message = 'Message too large'; break
|
||||
case 1010: message = 'Extension negotiation failed'; break
|
||||
case 1011: message = 'Server encountered an error'; break
|
||||
case 1012: message = 'Server is restarting'; break
|
||||
case 1013: message = 'Server is overloaded, try again later'; break
|
||||
case 1014: message = 'Bad gateway'; break
|
||||
case 1015: message = 'TLS handshake failed'; break
|
||||
default: message = `Connection closed (code ${e.code})`
|
||||
}
|
||||
} else {
|
||||
message = e.code === 1006
|
||||
? 'Connection lost unexpectedly'
|
||||
: `Connection closed with error (code ${e.code})`
|
||||
}
|
||||
this.#err = new Error(message)
|
||||
this.#waiting.splice(0).forEach(p => p.reject(this.#err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ async def get_reset(token: str) -> ResetToken:
|
||||
record = await db.instance.get_reset_token(reset_key(token))
|
||||
if record and record.expiry >= datetime.now(timezone.utc):
|
||||
return record
|
||||
raise ValueError("This reset link is invalid or has expired")
|
||||
raise ValueError("This authentication link is no longer valid.")
|
||||
|
||||
|
||||
async def get_session(token: str, host: str | None = None) -> Session:
|
||||
|
||||
@@ -201,7 +201,8 @@ async def get_settings():
|
||||
"rp_id": pk.rp_id,
|
||||
"rp_name": pk.rp_name,
|
||||
"ui_base_path": base_path,
|
||||
"auth_host": hostutil.configured_auth_host(),
|
||||
"auth_host": hostutil.dedicated_auth_host(),
|
||||
"auth_site_url": hostutil.auth_site_url(),
|
||||
"session_cookie": AUTH_COOKIE_NAME,
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ def redirect_to_root_on_auth_host(request: Request, cur: str, path: str) -> Resp
|
||||
|
||||
async def redirect_middleware(request: Request, call_next):
|
||||
"""Middleware to handle auth host redirects."""
|
||||
cfg = hostutil.configured_auth_host()
|
||||
cfg = hostutil.dedicated_auth_host()
|
||||
if not cfg:
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
+240
-86
@@ -19,9 +19,9 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from paskia import remoteauth
|
||||
from paskia.authsession import create_session
|
||||
from paskia.fastapi.session import infodict
|
||||
from paskia.fastapi.wsutil import require_pow, validate_origin, websocket_error_handler
|
||||
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
||||
from paskia.globals import db, passkey
|
||||
from paskia.util import hostutil, pow
|
||||
from paskia.util import hostutil, passphrase, pow
|
||||
|
||||
|
||||
# Create a FastAPI subapp for remote auth WebSocket endpoints
|
||||
@@ -51,8 +51,39 @@ async def websocket_remote_auth_request(ws: WebSocket):
|
||||
if remoteauth.instance is None:
|
||||
raise ValueError("Remote authentication is not available")
|
||||
|
||||
# Require HARD PoW before creating the request (SECURITY)
|
||||
await require_pow(ws, work=pow.HARD)
|
||||
# Track this WebSocket connection for load-based PoW difficulty
|
||||
remoteauth.instance.increment_connections()
|
||||
try:
|
||||
# Send PoW challenge immediately with dynamic difficulty based on load
|
||||
challenge = pow.generate_challenge()
|
||||
work = remoteauth.instance.get_pow_difficulty()
|
||||
|
||||
await ws.send_json({
|
||||
"pow": {
|
||||
"challenge": base64url.enc(challenge),
|
||||
"work": work,
|
||||
}
|
||||
})
|
||||
|
||||
# Receive client response with PoW solution and action
|
||||
response = await ws.receive_json()
|
||||
|
||||
# Verify PoW (required for this endpoint - SECURITY)
|
||||
solution_b64 = response.get("pow")
|
||||
if not solution_b64:
|
||||
raise ValueError("PoW solution required")
|
||||
|
||||
try:
|
||||
solution = base64url.dec(solution_b64)
|
||||
except Exception:
|
||||
raise ValueError("Invalid PoW solution encoding")
|
||||
|
||||
pow.verify_pow(challenge, solution, work)
|
||||
|
||||
# Extract action from the same message
|
||||
action = response.get("action", "login")
|
||||
if action not in ("login", "register"):
|
||||
action = "login"
|
||||
|
||||
metadata = infodict(ws, "remote-auth-request")
|
||||
|
||||
@@ -61,22 +92,19 @@ async def websocket_remote_auth_request(ws: WebSocket):
|
||||
host=host,
|
||||
ip=metadata.get("ip") or "",
|
||||
user_agent=metadata.get("user_agent") or "",
|
||||
action=action,
|
||||
)
|
||||
|
||||
# Build the URL for the authenticating device (same endpoint as reset tokens)
|
||||
url = hostutil.auth_site_base_url() + token
|
||||
|
||||
# Send the token, pairing code, and URL to the client
|
||||
# Send the token and pairing code to the client (URL built in frontend)
|
||||
await ws.send_json(
|
||||
{
|
||||
"token": token,
|
||||
"pairing_code": pairing_code,
|
||||
"url": url,
|
||||
"expires": expiry.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
)
|
||||
|
||||
# Set up async notification
|
||||
# Set up async notification for completion
|
||||
result_event = asyncio.Event()
|
||||
result_data: dict = {}
|
||||
|
||||
@@ -84,32 +112,54 @@ async def websocket_remote_auth_request(ws: WebSocket):
|
||||
session_token: str | None,
|
||||
user_uuid: UUID | None,
|
||||
credential_uuid: UUID | None,
|
||||
reset_token: str | None,
|
||||
):
|
||||
# Check if this was an explicit denial (UUID(int=0) is the signal)
|
||||
was_denied = user_uuid is not None and user_uuid == UUID(int=0)
|
||||
result_data["session_token"] = session_token
|
||||
result_data["user_uuid"] = user_uuid
|
||||
result_data["credential_uuid"] = credential_uuid
|
||||
result_data["reset_token"] = reset_token
|
||||
result_data["was_denied"] = was_denied
|
||||
result_event.set()
|
||||
|
||||
await remoteauth.instance.set_notify_callback(token, on_complete)
|
||||
|
||||
# Set up async notification for action lock
|
||||
locked_event = asyncio.Event()
|
||||
locked_data: dict = {}
|
||||
|
||||
def on_action_locked(action: str):
|
||||
locked_data["action"] = action
|
||||
locked_event.set()
|
||||
|
||||
await remoteauth.instance.set_action_locked_callback(token, on_action_locked)
|
||||
|
||||
# 5 minute timeout for the entire remote auth flow
|
||||
timeout_seconds = 5 * 60
|
||||
|
||||
try:
|
||||
# Wait for either:
|
||||
# 1. Authentication to complete (result_event set)
|
||||
# 2. Client to disconnect
|
||||
# 3. Client to send a cancel message
|
||||
# 4. Timeout after 5 minutes
|
||||
# 2. Action locked (locked_event set)
|
||||
# 3. Client to disconnect
|
||||
# 4. Client to send a cancel or update_action message
|
||||
# 5. Timeout after 5 minutes
|
||||
|
||||
async with asyncio.timeout(timeout_seconds):
|
||||
while True:
|
||||
# Use asyncio.wait to handle both event and websocket
|
||||
# Use asyncio.wait to handle events and websocket
|
||||
receive_task = asyncio.create_task(ws.receive_json())
|
||||
event_task = asyncio.create_task(result_event.wait())
|
||||
result_wait_task = asyncio.create_task(result_event.wait())
|
||||
locked_wait_task = asyncio.create_task(locked_event.wait())
|
||||
|
||||
tasks = [receive_task, result_wait_task]
|
||||
# Only wait for locked event if not already locked
|
||||
if not locked_event.is_set():
|
||||
tasks.append(locked_wait_task)
|
||||
|
||||
done, pending = await asyncio.wait(
|
||||
[receive_task, event_task],
|
||||
tasks,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
@@ -121,14 +171,26 @@ async def websocket_remote_auth_request(ws: WebSocket):
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if event_task in done:
|
||||
# Authentication completed (or expired/cancelled)
|
||||
if result_data.get("session_token"):
|
||||
await ws.send_json(
|
||||
{
|
||||
if result_wait_task in done:
|
||||
# Authentication completed (or expired/cancelled/denied)
|
||||
was_denied = result_data.get("was_denied", False)
|
||||
if result_data.get("session_token") or result_data.get("reset_token"):
|
||||
response = {
|
||||
"status": "authenticated",
|
||||
"user_uuid": str(result_data["user_uuid"]),
|
||||
"session_token": result_data["session_token"],
|
||||
}
|
||||
if result_data.get("session_token"):
|
||||
response["session_token"] = result_data["session_token"]
|
||||
if result_data.get("reset_token"):
|
||||
response["reset_token"] = result_data["reset_token"]
|
||||
await ws.send_json(response)
|
||||
else:
|
||||
# Check if it was explicitly denied
|
||||
if was_denied:
|
||||
await ws.send_json(
|
||||
{
|
||||
"status": "denied",
|
||||
"detail": "Access denied",
|
||||
}
|
||||
)
|
||||
else:
|
||||
@@ -140,6 +202,14 @@ async def websocket_remote_auth_request(ws: WebSocket):
|
||||
)
|
||||
return
|
||||
|
||||
if locked_wait_task in done:
|
||||
# Action was locked by the authenticating device
|
||||
await ws.send_json({
|
||||
"status": "locked",
|
||||
"action": locked_data.get("action", "login"),
|
||||
})
|
||||
# Continue waiting for result
|
||||
|
||||
if receive_task in done:
|
||||
# Client sent a message
|
||||
msg = receive_task.result()
|
||||
@@ -147,6 +217,10 @@ async def websocket_remote_auth_request(ws: WebSocket):
|
||||
await remoteauth.instance.cancel_request(token)
|
||||
await ws.send_json({"status": "cancelled"})
|
||||
return
|
||||
elif msg.get("action") == "update_action":
|
||||
# Update the action (login/register) if not locked
|
||||
new_action = "register" if msg.get("register") else "login"
|
||||
await remoteauth.instance.update_action(token, new_action)
|
||||
# Ignore other messages
|
||||
|
||||
except TimeoutError:
|
||||
@@ -159,11 +233,14 @@ async def websocket_remote_auth_request(ws: WebSocket):
|
||||
}
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
# Client disconnected, cancel the request
|
||||
await remoteauth.instance.cancel_request(token)
|
||||
# Client disconnected, cancel the request and mark as denied
|
||||
await remoteauth.instance.cancel_request(token, denied=True)
|
||||
except Exception:
|
||||
await remoteauth.instance.cancel_request(token)
|
||||
raise
|
||||
finally:
|
||||
# Decrement connection count
|
||||
remoteauth.instance.decrement_connections()
|
||||
|
||||
|
||||
@app.websocket("/pair")
|
||||
@@ -197,7 +274,7 @@ async def websocket_remote_auth_pair(ws: WebSocket):
|
||||
if remoteauth.instance is None:
|
||||
raise ValueError("Remote authentication is not available")
|
||||
|
||||
# Generate initial PoW challenge
|
||||
# Generate initial PoW challenge (always NORMAL for authenticated users)
|
||||
challenge = pow.generate_challenge()
|
||||
work = pow.NORMAL
|
||||
|
||||
@@ -210,11 +287,114 @@ async def websocket_remote_auth_pair(ws: WebSocket):
|
||||
|
||||
request = None
|
||||
webauthn_challenge = None
|
||||
explicitly_denied = False
|
||||
|
||||
try:
|
||||
while True:
|
||||
msg = await ws.receive_json()
|
||||
|
||||
# Check if this is a 5-word token (from link) - skip PoW validation
|
||||
# Handle deny request first (no PoW needed - already validated during lookup)
|
||||
if msg.get("deny") and request is not None:
|
||||
# Cancel the request and mark it as denied
|
||||
explicitly_denied = True
|
||||
await remoteauth.instance.cancel_request(request.key, denied=True)
|
||||
await ws.send_json({
|
||||
"status": "denied",
|
||||
"message": "Request denied",
|
||||
})
|
||||
break
|
||||
|
||||
# Handle authenticate request (no PoW needed - already validated during lookup)
|
||||
if msg.get("authenticate") and request is not None:
|
||||
# Generate authentication options
|
||||
options, webauthn_challenge = passkey.instance.auth_generate_options(
|
||||
credential_ids=None
|
||||
)
|
||||
await ws.send_json({"optionsJSON": options})
|
||||
|
||||
# Wait for WebAuthn response
|
||||
credential = passkey.instance.auth_parse(await ws.receive_json())
|
||||
|
||||
# Fetch and verify credential
|
||||
try:
|
||||
stored_cred = await db.instance.get_credential_by_id(credential.raw_id)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"This passkey is no longer registered with {passkey.instance.rp_name}"
|
||||
)
|
||||
|
||||
# Verify the credential
|
||||
passkey.instance.auth_verify(
|
||||
credential, webauthn_challenge, stored_cred, origin
|
||||
)
|
||||
|
||||
# Update credential last_used
|
||||
await db.instance.login(stored_cred.user_uuid, stored_cred)
|
||||
|
||||
# Create a session for the REQUESTING device
|
||||
assert stored_cred.uuid is not None
|
||||
|
||||
session_token = None
|
||||
reset_token = None
|
||||
|
||||
if request.action == "register":
|
||||
# For registration, create a reset token for device addition
|
||||
from paskia.authsession import expires
|
||||
from paskia.util import tokens
|
||||
|
||||
token_str = passphrase.generate()
|
||||
expiry = expires()
|
||||
await db.instance.create_reset_token(
|
||||
user_uuid=stored_cred.user_uuid,
|
||||
key=tokens.reset_key(token_str),
|
||||
expiry=expiry,
|
||||
token_type="device addition",
|
||||
)
|
||||
reset_token = token_str
|
||||
# Also create a session so the device is logged in?
|
||||
# User requested: "We can make the flow always create a new session, but make additional tokens for other possibilities."
|
||||
session_token = await create_session(
|
||||
user_uuid=stored_cred.user_uuid,
|
||||
credential_uuid=stored_cred.uuid,
|
||||
host=request.host,
|
||||
ip=request.ip,
|
||||
user_agent=request.user_agent,
|
||||
)
|
||||
else:
|
||||
# Default login action
|
||||
session_token = await create_session(
|
||||
user_uuid=stored_cred.user_uuid,
|
||||
credential_uuid=stored_cred.uuid,
|
||||
host=request.host,
|
||||
ip=request.ip,
|
||||
user_agent=request.user_agent,
|
||||
)
|
||||
|
||||
# Complete the remote auth request (notifies the waiting device)
|
||||
completed = await remoteauth.instance.complete_request(
|
||||
token=request.key,
|
||||
session_token=session_token,
|
||||
user_uuid=stored_cred.user_uuid,
|
||||
credential_uuid=stored_cred.uuid,
|
||||
reset_token=reset_token,
|
||||
)
|
||||
|
||||
if not completed:
|
||||
raise ValueError("Failed to complete remote authentication")
|
||||
|
||||
msg = "Authentication successful."
|
||||
if request.action == "register":
|
||||
msg += " The other device can now register a passkey."
|
||||
else:
|
||||
msg += " The other device is now logged in."
|
||||
|
||||
await ws.send_json({
|
||||
"status": "success",
|
||||
"message": msg,
|
||||
})
|
||||
break
|
||||
|
||||
# Handle code lookup request - requires PoW validation
|
||||
code = msg.get("code", "")
|
||||
is_link_token = len(code.split(".")) == 5
|
||||
|
||||
@@ -244,61 +424,6 @@ async def websocket_remote_auth_pair(ws: WebSocket):
|
||||
})
|
||||
continue
|
||||
|
||||
# Handle authenticate request (after successful lookup)
|
||||
if msg.get("authenticate") and request is not None:
|
||||
# Generate authentication options
|
||||
options, webauthn_challenge = passkey.instance.auth_generate_options(
|
||||
credential_ids=None
|
||||
)
|
||||
await ws.send_json({"optionsJSON": options})
|
||||
|
||||
# Wait for WebAuthn response
|
||||
credential = passkey.instance.auth_parse(await ws.receive_json())
|
||||
|
||||
# Fetch and verify credential
|
||||
try:
|
||||
stored_cred = await db.instance.get_credential_by_id(credential.raw_id)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"This passkey is no longer registered with {passkey.instance.rp_name}"
|
||||
)
|
||||
|
||||
# Verify the credential
|
||||
passkey.instance.auth_verify(
|
||||
credential, webauthn_challenge, stored_cred, origin
|
||||
)
|
||||
|
||||
# Update credential last_used
|
||||
await db.instance.login(stored_cred.user_uuid, stored_cred)
|
||||
|
||||
# Create a session for the REQUESTING device
|
||||
assert stored_cred.uuid is not None
|
||||
session_token = await create_session(
|
||||
user_uuid=stored_cred.user_uuid,
|
||||
credential_uuid=stored_cred.uuid,
|
||||
host=request.host,
|
||||
ip=request.ip,
|
||||
user_agent=request.user_agent,
|
||||
)
|
||||
|
||||
# Complete the remote auth request (notifies the waiting device)
|
||||
completed = await remoteauth.instance.complete_request(
|
||||
token=request.key,
|
||||
session_token=session_token,
|
||||
user_uuid=stored_cred.user_uuid,
|
||||
credential_uuid=stored_cred.uuid,
|
||||
)
|
||||
|
||||
if not completed:
|
||||
raise ValueError("Failed to complete remote authentication")
|
||||
|
||||
await ws.send_json({
|
||||
"status": "success",
|
||||
"message": "Authentication successful. The other device is now logged in.",
|
||||
})
|
||||
break
|
||||
|
||||
# Handle code lookup request
|
||||
if not code:
|
||||
raise ValueError("Pairing code required")
|
||||
|
||||
@@ -308,7 +433,7 @@ async def websocket_remote_auth_pair(ws: WebSocket):
|
||||
else:
|
||||
request = await remoteauth.instance.get_request_by_pairing_code(code)
|
||||
|
||||
# Generate new challenge for next request
|
||||
# Generate new challenge for next request (always NORMAL for authenticated users)
|
||||
challenge = pow.generate_challenge()
|
||||
|
||||
if request is None:
|
||||
@@ -323,13 +448,42 @@ async def websocket_remote_auth_pair(ws: WebSocket):
|
||||
request = None # Reset for next attempt
|
||||
continue
|
||||
|
||||
# Valid code found - send device info
|
||||
# Valid code found - lock the action so it can't be changed anymore
|
||||
# This also notifies the requesting device
|
||||
locked_action = await remoteauth.instance.lock_action(request.key)
|
||||
if locked_action is None:
|
||||
# Already locked by another device
|
||||
await ws.send_json({
|
||||
"status": "found",
|
||||
"host": request.host,
|
||||
"user_agent_pretty": useragent.compact_user_agent(request.user_agent),
|
||||
"status": 409,
|
||||
"detail": "This request is already being processed in another window",
|
||||
"pow": {
|
||||
"challenge": base64url.enc(challenge),
|
||||
"work": work,
|
||||
}
|
||||
})
|
||||
request = None # Reset for next attempt
|
||||
continue
|
||||
|
||||
request.action = locked_action # Update local copy with locked value
|
||||
|
||||
# Send device info to the authenticating device
|
||||
await ws.send_json({
|
||||
"status": "found",
|
||||
"host": request.host,
|
||||
"user_agent_pretty": useragent.compact_user_agent(request.user_agent),
|
||||
"client_ip": request.ip,
|
||||
"action": request.action,
|
||||
"pow": {
|
||||
"challenge": base64url.enc(challenge),
|
||||
"work": work,
|
||||
}
|
||||
})
|
||||
except Exception:
|
||||
# If websocket disconnects without explicit denial, unlock the request
|
||||
if request and not explicitly_denied:
|
||||
# Unlock the request so the code can be used again
|
||||
async with remoteauth.instance._lock:
|
||||
req = remoteauth.instance._requests.get(request.key)
|
||||
if req and req.locked:
|
||||
req.locked = False
|
||||
raise
|
||||
|
||||
+120
-11
@@ -43,14 +43,21 @@ class RemoteAuthRequest:
|
||||
host: str # The host where the session should be created
|
||||
ip: str # IP of the requesting device
|
||||
user_agent: str # User agent of the requesting device
|
||||
action: str = "login" # "login" or "register"
|
||||
locked: bool = False # True once the authenticating device has entered the code
|
||||
# Callback to notify the requesting device when auth completes
|
||||
# Takes (session_token, user_uuid, credential_uuid) or (None, None, None) on cancel/expire
|
||||
notify: Callable[[str | None, UUID | None, UUID | None], None] | None = None
|
||||
# Takes (session_token, user_uuid, credential_uuid, reset_token) or (None, None, None, None) on cancel/expire
|
||||
notify: Callable[[str | None, UUID | None, UUID | None, str | None], None] | None = None
|
||||
# Callback to notify the requesting device when action is locked
|
||||
# Takes (action) to confirm what action was locked
|
||||
action_locked_notify: Callable[[str], None] | None = None
|
||||
# Set when authentication completes
|
||||
completed: bool = False
|
||||
denied: bool = False # True if explicitly denied by the authenticating device
|
||||
session_token: str | None = None
|
||||
user_uuid: UUID | None = None
|
||||
credential_uuid: UUID | None = None
|
||||
reset_token: str | None = None
|
||||
|
||||
|
||||
def _generate_pairing_code() -> str:
|
||||
@@ -110,7 +117,7 @@ class RemoteAuthManager:
|
||||
self._by_pairing_code.pop(req.pairing_code, None)
|
||||
if req.notify and not req.completed:
|
||||
try:
|
||||
req.notify(None, None, None)
|
||||
req.notify(None, None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -119,6 +126,7 @@ class RemoteAuthManager:
|
||||
host: str,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
action: str = "login",
|
||||
) -> tuple[str, str, datetime]:
|
||||
"""Create a new remote auth request.
|
||||
|
||||
@@ -151,6 +159,7 @@ class RemoteAuthManager:
|
||||
host=host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
action=action,
|
||||
)
|
||||
|
||||
self._requests[token] = request
|
||||
@@ -200,7 +209,7 @@ class RemoteAuthManager:
|
||||
async def set_notify_callback(
|
||||
self,
|
||||
token: str,
|
||||
callback: Callable[[str | None, UUID | None, UUID | None], None],
|
||||
callback: Callable[[str | None, UUID | None, UUID | None, str | None], None],
|
||||
) -> bool:
|
||||
"""Set the notification callback for a request.
|
||||
|
||||
@@ -213,12 +222,72 @@ class RemoteAuthManager:
|
||||
req.notify = callback
|
||||
return True
|
||||
|
||||
async def set_action_locked_callback(
|
||||
self,
|
||||
token: str,
|
||||
callback: Callable[[str], None],
|
||||
) -> bool:
|
||||
"""Set the callback for when the action is locked.
|
||||
|
||||
Returns True if the request exists and callback was set.
|
||||
"""
|
||||
async with self._lock:
|
||||
req = self._requests.get(token)
|
||||
if req is None:
|
||||
return False
|
||||
req.action_locked_notify = callback
|
||||
return True
|
||||
|
||||
async def update_action(
|
||||
self,
|
||||
token: str,
|
||||
action: str,
|
||||
) -> bool:
|
||||
"""Update the action for a request (only if not locked).
|
||||
|
||||
Returns True if the request exists and was updated.
|
||||
"""
|
||||
if action not in ("login", "register"):
|
||||
return False
|
||||
async with self._lock:
|
||||
req = self._requests.get(token)
|
||||
if req is None or req.locked:
|
||||
return False
|
||||
req.action = action
|
||||
return True
|
||||
|
||||
async def lock_action(
|
||||
self,
|
||||
token: str,
|
||||
) -> str | None:
|
||||
"""Lock the action for a request (called when authenticating device enters code).
|
||||
|
||||
Returns the locked action, or None if request doesn't exist or is already locked.
|
||||
Notifies the requesting device via action_locked_notify callback.
|
||||
"""
|
||||
async with self._lock:
|
||||
req = self._requests.get(token)
|
||||
if req is None:
|
||||
return None
|
||||
if req.locked:
|
||||
# Already locked by another authenticating device
|
||||
return None
|
||||
req.locked = True
|
||||
action = req.action
|
||||
if req.action_locked_notify:
|
||||
try:
|
||||
req.action_locked_notify(action)
|
||||
except Exception:
|
||||
pass
|
||||
return action
|
||||
|
||||
async def complete_request(
|
||||
self,
|
||||
token: str,
|
||||
session_token: str,
|
||||
session_token: str | None,
|
||||
user_uuid: UUID,
|
||||
credential_uuid: UUID,
|
||||
reset_token: str | None = None,
|
||||
) -> bool:
|
||||
"""Mark a request as completed with the authentication result.
|
||||
|
||||
@@ -233,27 +302,67 @@ class RemoteAuthManager:
|
||||
self._by_pairing_code.pop(req.pairing_code, None)
|
||||
if req.notify:
|
||||
try:
|
||||
req.notify(session_token, user_uuid, credential_uuid)
|
||||
req.notify(session_token, user_uuid, credential_uuid, reset_token)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
async def cancel_request(self, token: str) -> bool:
|
||||
async def cancel_request(self, token: str, *, denied: bool = False) -> RemoteAuthRequest | None:
|
||||
"""Cancel and remove a request.
|
||||
|
||||
Returns True if the request existed and was removed.
|
||||
Args:
|
||||
token: The request token
|
||||
denied: If True, marks this as an explicit denial (not just timeout/disconnect)
|
||||
|
||||
Returns the removed request if it existed, None otherwise.
|
||||
"""
|
||||
async with self._lock:
|
||||
req = self._requests.pop(token, None)
|
||||
if req is None:
|
||||
return False
|
||||
return None
|
||||
self._by_pairing_code.pop(req.pairing_code, None)
|
||||
if denied:
|
||||
req.denied = True
|
||||
if req.notify and not req.completed:
|
||||
try:
|
||||
req.notify(None, None, None)
|
||||
# Pass denied status through a special UUID value (all zeros means denied)
|
||||
if denied:
|
||||
req.notify(None, UUID(int=0), None, None)
|
||||
else:
|
||||
req.notify(None, None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
return req
|
||||
|
||||
def get_connection_count(self) -> int:
|
||||
"""Get the current count of open WebSocket connections.
|
||||
|
||||
This is used to determine PoW difficulty based on load.
|
||||
"""
|
||||
# Count is maintained externally by the WebSocket endpoints
|
||||
return getattr(self, '_ws_count', 0)
|
||||
|
||||
def increment_connections(self) -> None:
|
||||
"""Increment the WebSocket connection counter."""
|
||||
self._ws_count = getattr(self, '_ws_count', 0) + 1
|
||||
|
||||
def decrement_connections(self) -> None:
|
||||
"""Decrement the WebSocket connection counter."""
|
||||
self._ws_count = max(0, getattr(self, '_ws_count', 0) - 1)
|
||||
|
||||
def get_pow_difficulty(self) -> int:
|
||||
"""Get PoW difficulty based on current WebSocket connection count.
|
||||
|
||||
Uses NORMAL difficulty with low load (< 10 connections),
|
||||
HARD difficulty with high load (>= 10 connections).
|
||||
|
||||
Returns:
|
||||
PoW work units (pow.NORMAL or pow.HARD)
|
||||
"""
|
||||
from paskia.util import pow
|
||||
|
||||
count = self.get_connection_count()
|
||||
return pow.HARD if count >= 10 else pow.NORMAL
|
||||
|
||||
async def consume_request(self, token: str) -> RemoteAuthRequest | None:
|
||||
"""Get and remove a request (for use by the authenticating device)."""
|
||||
|
||||
@@ -19,7 +19,7 @@ def is_root_mode() -> bool:
|
||||
return _load_config().get("auth_host") is not None
|
||||
|
||||
|
||||
def configured_auth_host() -> str | None:
|
||||
def dedicated_auth_host() -> str | None:
|
||||
"""Return configured auth_host netloc, or None."""
|
||||
auth_host = _load_config().get("auth_host")
|
||||
if not auth_host:
|
||||
@@ -34,7 +34,7 @@ def ui_base_path() -> str:
|
||||
return "/" if is_root_mode() else "/auth/"
|
||||
|
||||
|
||||
def auth_site_base_url() -> str:
|
||||
def auth_site_url() -> str:
|
||||
"""Return the base URL for the auth site UI (computed at startup)."""
|
||||
cfg = _load_config()
|
||||
return cfg.get("site_url", "https://localhost") + cfg.get("site_path", "/auth/")
|
||||
@@ -42,7 +42,7 @@ def auth_site_base_url() -> str:
|
||||
|
||||
def reset_link_url(token: str) -> str:
|
||||
"""Generate a reset link URL for the given token."""
|
||||
return f"{auth_site_base_url()}{token}"
|
||||
return f"{auth_site_url()}{token}"
|
||||
|
||||
|
||||
def normalize_origin(origin: str) -> str:
|
||||
|
||||
Reference in New Issue
Block a user