Cleanup, restore reset link functionality as it were, ruff and removed leftover remoteauth functionality.

This commit is contained in:
Leo Vasanko
2025-12-08 23:51:51 +00:00
parent 99c60f0e16
commit f3118b7c1f
13 changed files with 181 additions and 543 deletions
+7 -82
View File
@@ -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>
+7 -164
View File
@@ -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) {
+1 -46
View File
@@ -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)
})