Cleanup, restore reset link functionality as it were, ruff and removed leftover remoteauth functionality.
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
<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">
|
||||
<!-- Code input (shown when device info not yet received) -->
|
||||
<div v-if="!deviceInfo" 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">
|
||||
@@ -95,8 +95,7 @@ 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)
|
||||
action: { type: String, default: 'login' } // 'login' or 'register'
|
||||
})
|
||||
|
||||
const emit = defineEmits(['completed', 'error', 'cancelled', 'back', 'register', 'deviceInfoVisible'])
|
||||
@@ -591,7 +590,7 @@ async function submitCode() {
|
||||
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)
|
||||
const authResponse = await startAuthentication(res)
|
||||
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')
|
||||
@@ -616,70 +615,6 @@ async function submitCode() {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -694,18 +629,8 @@ async function deny() {
|
||||
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()
|
||||
}
|
||||
// Reset to initial state
|
||||
reset()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
@@ -741,7 +666,7 @@ onUnmounted(() => {
|
||||
if (ws) { ws.close(); ws = null }
|
||||
})
|
||||
|
||||
defineExpose({ reset, deny, code, handleInput, authenticateWithToken, loading, error })
|
||||
defineExpose({ reset, deny, code, handleInput, loading, error })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -23,19 +23,15 @@
|
||||
</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>
|
||||
<span>Generating code…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Waiting/Authenticating phase - show codes and QR -->
|
||||
<!-- Waiting/Authenticating phase - show codes -->
|
||||
<div v-else class="auth-display">
|
||||
<div class="auth-content">
|
||||
<div v-if="pairingCode" class="pairing-code-section">
|
||||
@@ -47,16 +43,6 @@
|
||||
</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">
|
||||
@@ -68,8 +54,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import QRCode from 'qrcode/lib/browser'
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import aWebSocket from '@/utils/awaitable-websocket'
|
||||
import { dec as b64dec, enc as b64enc } from '@/utils/base64url'
|
||||
import { getSettings } from '@/utils/settings'
|
||||
@@ -82,18 +67,13 @@ const props = defineProps({
|
||||
|
||||
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, ' ') : '')
|
||||
@@ -167,9 +147,7 @@ function stopWordAnimation() {
|
||||
async function startRemoteAuth() {
|
||||
error.value = null
|
||||
completed.value = false
|
||||
url.value = null
|
||||
pairingCode.value = null
|
||||
expires.value = null
|
||||
phase.value = 'connecting'
|
||||
|
||||
// Start word animation
|
||||
@@ -191,27 +169,20 @@ async function startRemoteAuth() {
|
||||
ws.send_json({ pow: b64enc(nonces), action: 'login' })
|
||||
}
|
||||
|
||||
// Receive the remote auth token and pairing code
|
||||
// Receive the 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()
|
||||
@@ -254,40 +225,6 @@ async function startRemoteAuth() {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -301,7 +238,7 @@ function cancel() {
|
||||
}
|
||||
|
||||
watch(() => props.active, (newVal) => {
|
||||
if (newVal && !url.value && !error.value && !completed.value) {
|
||||
if (newVal && !pairingCode.value && !error.value && !completed.value) {
|
||||
startRemoteAuth()
|
||||
}
|
||||
})
|
||||
@@ -317,9 +254,6 @@ onUnmounted(() => {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
if (copyToastTimer) {
|
||||
clearTimeout(copyToastTimer)
|
||||
}
|
||||
stopWordAnimation()
|
||||
})
|
||||
|
||||
@@ -519,66 +453,7 @@ defineExpose({ retry, cancel })
|
||||
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 {
|
||||
.waiting-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -632,33 +507,6 @@ defineExpose({ retry, cancel })
|
||||
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 {
|
||||
@@ -667,15 +515,10 @@ defineExpose({ retry, cancel })
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pairing-code-section,
|
||||
.qr-section {
|
||||
.pairing-code-section {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.qr-section {
|
||||
max-width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
|
||||
@@ -47,28 +47,6 @@
|
||||
@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>
|
||||
@@ -82,17 +60,12 @@ import passkey from '@/utils/passkey'
|
||||
import { getSettings, uiBasePath } from '@/utils/settings'
|
||||
import { fetchJson, getUserFriendlyErrorMessage } from '@/utils/api'
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -104,8 +77,7 @@ const loading = ref(false)
|
||||
const settings = ref(null)
|
||||
const userInfo = ref(null)
|
||||
const currentView = ref('initial') // 'initial', 'login', 'forbidden'
|
||||
const authView = ref('local') // 'local', 'remote', or 'complete'
|
||||
const remoteAuthRef = ref(null)
|
||||
const authView = ref('local') // 'local' or 'remote'
|
||||
let statusTimer = null
|
||||
|
||||
const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
|
||||
@@ -118,9 +90,6 @@ const canAuthenticate = computed(() => {
|
||||
})
|
||||
|
||||
const headingTitle = computed(() => {
|
||||
if (authView.value === 'complete') {
|
||||
return `🔐 ${settings.value?.rp_name || location.origin}`
|
||||
}
|
||||
if (props.mode === 'reauth') {
|
||||
return `🔐 Additional Authentication`
|
||||
}
|
||||
@@ -129,9 +98,6 @@ 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.'
|
||||
}
|
||||
@@ -276,12 +242,6 @@ function handleRemoteAuthError(errorMsg) {
|
||||
// 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')) {
|
||||
@@ -300,11 +260,6 @@ onMounted(async () => {
|
||||
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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user