Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bed2c39d8 |
No files matched your search
@@ -3,6 +3,7 @@ dist/
|
||||
.*
|
||||
!.gitignore
|
||||
*.lock
|
||||
package-lock.json
|
||||
paskia.sqlite
|
||||
/paskia/frontend-build
|
||||
/paskia/_version.py
|
||||
|
||||
@@ -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,35 @@
|
||||
<template>
|
||||
<RestrictedAuth
|
||||
:mode="authMode"
|
||||
:remote-auth-token="remoteAuthToken"
|
||||
@authenticated="handleAuthenticated"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import RestrictedAuth from '@/components/RestrictedAuth.vue'
|
||||
|
||||
// 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 /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 5-word passphrase
|
||||
const parts = token.split('.')
|
||||
if (parts.length === 5 && parts.every(p => p.length > 0)) {
|
||||
return token
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Detect mode from URL hash fragment
|
||||
const authMode = computed(() => {
|
||||
const params = new URLSearchParams(window.location.hash.slice(1))
|
||||
@@ -40,6 +60,9 @@ function handleBack() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Check for remote auth token in URL
|
||||
remoteAuthToken.value = extractRemoteToken()
|
||||
|
||||
postToParent({
|
||||
type: 'auth-ready'
|
||||
})
|
||||
|
||||
@@ -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,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">
|
||||
@@ -84,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>
|
||||
@@ -102,6 +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 RemoteAuth from '@/components/RemoteAuthPermit.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { adminUiPath, makeUiHref } from '@/utils/settings'
|
||||
import passkey from '@/utils/passkey'
|
||||
@@ -116,6 +127,8 @@ 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 || '' })
|
||||
|
||||
@@ -138,6 +151,19 @@ const addNewCredential = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handlePairingCompleted = () => {
|
||||
authStore.showMessage('The other device is now signed in!', 'success', 4000)
|
||||
// Reset the form after a delay
|
||||
setTimeout(() => pairingEntry.value?.reset(), 3000)
|
||||
}
|
||||
|
||||
const handlePairingError = (message) => {
|
||||
// Error is already shown in the component, optionally show global message for severe errors
|
||||
if (!message.includes('cancelled')) {
|
||||
authStore.showMessage(message, 'error', 4000)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (credential) => {
|
||||
const credentialId = credential?.credential_uuid
|
||||
if (!credentialId) return
|
||||
@@ -199,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>
|
||||
<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>
|
||||
</p>
|
||||
<!-- 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">
|
||||
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 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>
|
||||
@@ -0,0 +1,894 @@
|
||||
<template>
|
||||
<div class="pairing-entry">
|
||||
<form @submit.prevent="submitCode" class="pairing-form">
|
||||
<!-- 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">
|
||||
<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'
|
||||
})
|
||||
|
||||
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)
|
||||
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 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
|
||||
}
|
||||
|
||||
// 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, 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,535 @@
|
||||
<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>
|
||||
|
||||
<div class="waiting-indicator">
|
||||
<div class="spinner-small"></div>
|
||||
<span>Generating code…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Waiting/Authenticating phase - show codes -->
|
||||
<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>
|
||||
|
||||
<div class="waiting-indicator">
|
||||
<div class="spinner-small"></div>
|
||||
<span>{{ waitingMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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'
|
||||
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 pairingCode = ref(null)
|
||||
const completed = ref(false)
|
||||
const error = ref(null)
|
||||
const phase = ref('connecting')
|
||||
const settings = ref(null)
|
||||
const animatedWords = ref(['', '', ''])
|
||||
let ws = 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
|
||||
pairingCode.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 pairing code
|
||||
const res = await ws.receive_json()
|
||||
|
||||
if (res.status) {
|
||||
throw new Error(res.detail || `Failed to create remote auth request: ${res.status}`)
|
||||
}
|
||||
|
||||
pairingCode.value = res.pairing_code
|
||||
|
||||
// Stop word animation
|
||||
stopWordAnimation()
|
||||
|
||||
phase.value = 'waiting'
|
||||
|
||||
// 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 retry() {
|
||||
startRemoteAuth()
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
emit('cancelled')
|
||||
}
|
||||
|
||||
watch(() => props.active, (newVal) => {
|
||||
if (newVal && !pairingCode.value && !error.value && !completed.value) {
|
||||
startRemoteAuth()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.active) {
|
||||
startRemoteAuth()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 640px) {
|
||||
.auth-content {
|
||||
gap: 1.5rem;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pairing-code-section {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
@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,27 +11,41 @@
|
||||
<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">
|
||||
<div class="button-row center">
|
||||
<slot name="actions"
|
||||
:loading="loading"
|
||||
:can-authenticate="canAuthenticate"
|
||||
:is-authenticated="isAuthenticated"
|
||||
:authenticate="authenticateUser"
|
||||
:logout="logoutUser"
|
||||
: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="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>
|
||||
<!-- Local passkey authentication view -->
|
||||
<div v-if="authView === 'local'" class="auth-view">
|
||||
<div class="button-row center">
|
||||
<slot name="actions"
|
||||
:loading="loading"
|
||||
:can-authenticate="canAuthenticate"
|
||||
:is-authenticated="isAuthenticated"
|
||||
:authenticate="authenticateUser"
|
||||
:logout="logoutUser"
|
||||
: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="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>
|
||||
</div>
|
||||
</section>
|
||||
@@ -41,10 +55,11 @@
|
||||
</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 RemoteAuthInline from '@/components/RemoteAuthRequest.vue'
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
@@ -62,17 +77,15 @@ const loading = ref(false)
|
||||
const settings = ref(null)
|
||||
const userInfo = ref(null)
|
||||
const currentView = ref('initial') // 'initial', 'login', 'forbidden'
|
||||
const authView = ref('local') // 'local' or 'remote'
|
||||
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
|
||||
})
|
||||
|
||||
@@ -91,6 +104,12 @@ const headerMessage = computed(() => {
|
||||
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.'
|
||||
})
|
||||
|
||||
@@ -122,7 +141,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)
|
||||
@@ -131,7 +149,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)
|
||||
}
|
||||
@@ -170,7 +187,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) {
|
||||
@@ -181,7 +197,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()
|
||||
}
|
||||
@@ -196,10 +211,61 @@ async function setSessionCookie(result) {
|
||||
})
|
||||
}
|
||||
|
||||
function switchToRemote() {
|
||||
authView.value = 'remote'
|
||||
}
|
||||
|
||||
function switchToLocal() {
|
||||
authView.value = 'local'
|
||||
}
|
||||
|
||||
async function handleRemoteAuthenticated(result) {
|
||||
showMessage('Authenticated from another device!', 'success', 2000)
|
||||
try {
|
||||
await setSessionCookie(result)
|
||||
} catch (error) {
|
||||
const message = error?.message || 'Failed to establish session'
|
||||
showMessage(message, 'error', 4000)
|
||||
emit('auth-error', { message, cancelled: false })
|
||||
return
|
||||
}
|
||||
emit('authenticated', result)
|
||||
}
|
||||
|
||||
function handleRemoteRegistration(token) {
|
||||
showMessage('Registration approved! Redirecting...', 'success', 2000)
|
||||
const basePath = uiBasePath() || '/auth/'
|
||||
window.location.href = `${basePath}${token}`
|
||||
}
|
||||
|
||||
function handleRemoteAuthError(errorMsg) {
|
||||
// Error is already shown in the RemoteAuth component, don't show toast
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// Add click handler for inline links
|
||||
document.addEventListener('click', handleHeaderLinkClick)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleHeaderLinkClick)
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
@@ -210,9 +276,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;
|
||||
@@ -222,4 +287,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* URL-safe Base64 encoding/decoding utilities.
|
||||
*
|
||||
* These functions handle base64url format (RFC 4648) which uses:
|
||||
* - '-' instead of '+'
|
||||
* - '_' instead of '/'
|
||||
* - No padding '=' characters
|
||||
*/
|
||||
|
||||
/**
|
||||
* Decode a base64url string to Uint8Array.
|
||||
* Handles both standard base64 and URL-safe base64 (with or without padding).
|
||||
* @param {string} str - Base64url encoded string
|
||||
* @returns {Uint8Array} - Decoded bytes
|
||||
*/
|
||||
export function dec(str) {
|
||||
// Convert URL-safe characters to standard base64
|
||||
const base64 = str.replace(/-/g, '+').replace(/_/g, '/')
|
||||
// Add padding if needed
|
||||
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
|
||||
return Uint8Array.from(atob(padded), c => c.charCodeAt(0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a Uint8Array to base64url string.
|
||||
* @param {Uint8Array} bytes - Bytes to encode
|
||||
* @returns {string} - Base64url encoded string (no padding)
|
||||
*/
|
||||
export function enc(bytes) {
|
||||
const base64 = btoa(String.fromCharCode(...bytes))
|
||||
// Convert to URL-safe and remove padding
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { solvePoW, verifyPoW } from './pow.js'
|
||||
|
||||
const TRIALS = 5
|
||||
const WORK = 10
|
||||
|
||||
async function test() {
|
||||
console.log(`Running ${TRIALS} trials with ${WORK} work units...\n`)
|
||||
|
||||
const times = []
|
||||
|
||||
for (let trial = 1; trial <= TRIALS; trial++) {
|
||||
const challenge = crypto.getRandomValues(new Uint8Array(8))
|
||||
|
||||
const start = performance.now()
|
||||
const solution = await solvePoW(challenge, WORK)
|
||||
const elapsed = performance.now() - start
|
||||
|
||||
const valid = await verifyPoW(challenge, solution, WORK)
|
||||
|
||||
times.push(elapsed)
|
||||
|
||||
console.log(`Trial ${trial.toString().padStart(2)}: ${(elapsed / 1000).toFixed(3)}s, valid=${valid}`)
|
||||
}
|
||||
|
||||
const avgTime = times.reduce((a, b) => a + b, 0) / times.length
|
||||
const minTime = Math.min(...times)
|
||||
const maxTime = Math.max(...times)
|
||||
|
||||
console.log('\n--- Summary ---')
|
||||
console.log(`Trials: ${TRIALS}`)
|
||||
console.log(`Work units: ${WORK}`)
|
||||
console.log(`Avg time: ${(avgTime / 1000).toFixed(3)}s`)
|
||||
console.log(`Min time: ${(minTime / 1000).toFixed(3)}s`)
|
||||
console.log(`Max time: ${(maxTime / 1000).toFixed(3)}s`)
|
||||
}
|
||||
|
||||
test()
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Proof of Work utility using PBKDF2-SHA512
|
||||
*
|
||||
* The PoW requires finding nonces where PBKDF2(challenge, nonce) produces
|
||||
* output with a zero first byte. Each work unit requires finding one such nonce.
|
||||
* All valid nonces are concatenated into a solution for server verification.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Solve a Proof of Work challenge
|
||||
*
|
||||
* @param {Uint8Array|ArrayBuffer} challenge - 8-byte server-provided challenge
|
||||
* @param {number} work - Number of PBKDF2 work units required
|
||||
* @param {object} [options] - Optional parameters
|
||||
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the operation
|
||||
* @returns {Promise<Uint8Array>} Solution: concatenated 8-byte nonces (8 * work bytes)
|
||||
* @throws {Error} If challenge is invalid or operation is aborted
|
||||
*/
|
||||
export async function solvePoW(challenge, work, options = {}) {
|
||||
const { signal } = options
|
||||
const startTime = performance.now()
|
||||
|
||||
// Validate inputs
|
||||
const challengeBytes = challenge instanceof ArrayBuffer
|
||||
? new Uint8Array(challenge)
|
||||
: challenge
|
||||
|
||||
if (!(challengeBytes instanceof Uint8Array) || challengeBytes.length !== 8) {
|
||||
throw new Error('Challenge must be exactly 8 bytes')
|
||||
}
|
||||
|
||||
// Import challenge as PBKDF2 key material
|
||||
const baseKey = await crypto.subtle.importKey('raw', challengeBytes, 'PBKDF2', false, ['deriveBits'])
|
||||
|
||||
// Build solution from found nonces
|
||||
const solution = new Uint8Array(8 * work)
|
||||
let totalIterations = 0
|
||||
const mask = 0x7FF // The client must work 2048x harder than the server
|
||||
|
||||
// Sequential nonce starting at zero (little-endian, using Uint32Array for efficient increment)
|
||||
const nonce = new Uint32Array(2)
|
||||
|
||||
for (let i = 0; i < work; i++) {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('PoW operation aborted', 'AbortError')
|
||||
}
|
||||
|
||||
// Find a nonce where PBKDF2 output passes the mask check
|
||||
let result
|
||||
do {
|
||||
totalIterations++
|
||||
if (++nonce[0] === 0x100000000) ++nonce[1] // Increment 64-bit little-endian nonce
|
||||
result = new Uint32Array(await crypto.subtle.deriveBits(
|
||||
{ name: 'PBKDF2', salt: nonce, iterations: 128, hash: 'SHA-512'},
|
||||
baseKey,
|
||||
32
|
||||
))
|
||||
} while (result[0] & mask)
|
||||
solution.set(new Uint8Array(nonce.buffer), i * 8)
|
||||
}
|
||||
|
||||
const elapsed = (performance.now() - startTime) / 1000
|
||||
const expectedIterations = work * (mask + 1)
|
||||
const luckRatio = (totalIterations / expectedIterations).toFixed(1)
|
||||
const bench = totalIterations / ((mask + 1) * elapsed)
|
||||
console.log(`PoW work=${work} solved in ${elapsed.toFixed(2)}s (${luckRatio}x expected ${bench.toFixed(1)} work/s)`)
|
||||
return solution
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Auto-generated from paskia/util/wordlist.py - do not edit manually
|
||||
// A custom list of 1024 common 3-6 letter words, with unique 3-prefixes and no prefix words
|
||||
|
||||
export const words = ["able", "about", "absent", "abuse", "access", "acid", "across", "act", "adapt", "add", "adjust", "admit", "adult", "advice", "affair", "afraid", "again", "age", "agree", "ahead", "aim", "air", "aisle", "alarm", "album", "alert", "alien", "all", "almost", "alone", "alpha", "also", "alter", "always", "amazed", "among", "amused", "anchor", "angle", "animal", "ankle", "annual", "answer", "any", "apart", "appear", "april", "arch", "are", "argue", "army", "around", "array", "art", "ascent", "ash", "ask", "aspect", "assume", "asthma", "atom", "attack", "audit", "august", "aunt", "author", "avoid", "away", "awful", "axis", "baby", "back", "bad", "bag", "ball", "bamboo", "bank", "bar", "base", "battle", "beach", "become", "beef", "before", "begin", "behind", "below", "bench", "best", "better", "beyond", "bid", "bike", "bind", "bio", "birth", "bitter", "black", "bleak", "blind", "blood", "blue", "board", "body", "boil", "bomb", "bone", "book", "border", "boss", "bottom", "bounce", "bowl", "box", "boy", "brain", "bread", "bring", "brown", "brush", "bubble", "buck", "budget", "build", "bulk", "bundle", "burden", "bus", "but", "buyer", "buzz", "cable", "cache", "cage", "cake", "call", "came", "can", "car", "case", "catch", "cause", "cave", "celery", "cement", "census", "cereal", "change", "check", "child", "choice", "chunk", "cigar", "circle", "city", "civil", "class", "clean", "client", "close", "club", "coast", "code", "coffee", "coil", "cold", "come", "cool", "copy", "core", "cost", "cotton", "couch", "cover", "coyote", "craft", "cream", "crime", "cross", "cruel", "cry", "cube", "cue", "cult", "cup", "curve", "custom", "cute", "cycle", "dad", "damage", "danger", "daring", "dash", "dawn", "day", "deal", "debate", "decide", "deer", "define", "degree", "deity", "delay", "demand", "denial", "depth", "derive", "design", "detail", "device", "dial", "dice", "die", "differ", "dim", "dinner", "direct", "dish", "divert", "dizzy", "doctor", "dog", "dollar", "domain", "donate", "door", "dose", "double", "dove", "draft", "dream", "drive", "drop", "drum", "dry", "duck", "dumb", "dune", "during", "dust", "dutch", "dwarf", "eager", "early", "east", "echo", "eco", "edge", "edit", "effort", "egg", "eight", "either", "elbow", "elder", "elite", "else", "embark", "emerge", "emily", "employ", "enable", "end", "enemy", "engine", "enjoy", "enlist", "enough", "enrich", "ensure", "entire", "envy", "equal", "era", "erode", "error", "erupt", "escape", "essay", "estate", "ethics", "evil", "evoke", "exact", "excess", "exist", "exotic", "expect", "extent", "eye", "fabric", "face", "fade", "faith", "fall", "family", "fan", "far", "father", "fault", "feel", "female", "fence", "fetch", "fever", "few", "fiber", "field", "figure", "file", "find", "first", "fish", "fit", "fix", "flat", "flesh", "flight", "float", "fluid", "fly", "foam", "focus", "fog", "foil", "follow", "food", "force", "fossil", "found", "fox", "frame", "fresh", "friend", "frog", "fruit", "fuel", "fun", "fury", "future", "gadget", "gain", "galaxy", "game", "gap", "garden", "gas", "gate", "gauge", "gaze", "genius", "ghost", "giant", "gift", "giggle", "ginger", "girl", "give", "glass", "glide", "globe", "glue", "goal", "god", "gold", "good", "gospel", "govern", "gown", "grant", "great", "grid", "group", "grunt", "guard", "guess", "guide", "gulf", "gun", "gym", "habit", "hair", "half", "hammer", "hand", "happy", "hard", "hat", "have", "hawk", "hay", "hazard", "head", "hedge", "height", "help", "hen", "hero", "hidden", "high", "hill", "hint", "hip", "hire", "hobby", "hockey", "hold", "home", "honey", "hood", "hope", "horse", "host", "hotel", "hour", "hover", "how", "hub", "huge", "human", "hungry", "hurt", "hybrid", "ice", "icon", "idea", "idle", "ignore", "ill", "image", "immune", "impact", "income", "index", "infant", "inhale", "inject", "inmate", "inner", "input", "inside", "into", "invest", "iron", "island", "issue", "italy", "item", "ivory", "jacket", "jaguar", "james", "jar", "jazz", "jeans", "jelly", "jewel", "job", "joe", "joke", "joy", "judge", "juice", "july", "jump", "june", "just", "kansas", "kate", "keep", "kernel", "key", "kick", "kid", "kind", "kiss", "kit", "kiwi", "knee", "knife", "know", "labor", "lady", "lag", "lake", "lamp", "laptop", "large", "later", "laugh", "lava", "law", "layer", "lazy", "leader", "left", "legal", "lemon", "length", "lesson", "letter", "level", "liar", "libya", "lid", "life", "light", "like", "limit", "line", "lion", "liquid", "list", "little", "live", "lizard", "load", "local", "logic", "long", "loop", "lost", "loud", "love", "low", "loyal", "lucky", "lumber", "lunch", "lust", "luxury", "lyrics", "mad", "magic", "main", "major", "make", "male", "mammal", "man", "map", "market", "mass", "matter", "maze", "mccoy", "meadow", "media", "meet", "melt", "member", "men", "mercy", "mesh", "method", "middle", "milk", "mimic", "mind", "mirror", "miss", "mix", "mobile", "model", "mom", "monkey", "moon", "more", "mother", "mouse", "move"Line truncated
|
||||
|
||||
// Pre-computed prefix map for autocomplete (maps 1-3 char prefixes to matching words)
|
||||
const prefixMap = new Map();
|
||||
|
||||
// Build prefix map on module load
|
||||
for (const word of words) {
|
||||
for (let i = 1; i <= Math.min(word.length, 6); i++) {
|
||||
const prefix = word.slice(0, i);
|
||||
if (!prefixMap.has(prefix)) {
|
||||
prefixMap.set(prefix, []);
|
||||
}
|
||||
prefixMap.get(prefix).push(word);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find words matching the given prefix.
|
||||
* @param {string} prefix - The prefix to match (case-insensitive)
|
||||
* @returns {string[]} Array of matching words
|
||||
*/
|
||||
export function findMatches(prefix) {
|
||||
if (!prefix) return [];
|
||||
const normalized = prefix.toLowerCase();
|
||||
return prefixMap.get(normalized) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unique autocomplete suggestion if prefix matches exactly one word.
|
||||
* @param {string} prefix - The prefix to match (case-insensitive)
|
||||
* @returns {string|null} The matching word if unique, null otherwise
|
||||
*/
|
||||
export function getUniqueMatch(prefix) {
|
||||
const matches = findMatches(prefix);
|
||||
return matches.length === 1 ? matches[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a word is valid (exists in the wordlist).
|
||||
* @param {string} word - The word to check (case-insensitive)
|
||||
* @returns {boolean} True if the word is valid
|
||||
*/
|
||||
export function isValidWord(word) {
|
||||
if (!word) return false;
|
||||
const normalized = word.toLowerCase();
|
||||
return words.includes(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is a valid prefix (could be the start of a valid word).
|
||||
* @param {string} prefix - The prefix to check (case-insensitive)
|
||||
* @returns {boolean} True if the prefix matches at least one word
|
||||
*/
|
||||
export function isValidPrefix(prefix) {
|
||||
if (!prefix) return true; // Empty is always valid
|
||||
const normalized = prefix.toLowerCase();
|
||||
return prefixMap.has(normalized);
|
||||
}
|
||||
+10
-2
@@ -53,18 +53,26 @@ export default defineConfig(({ command }) => ({
|
||||
base: '/',
|
||||
server: {
|
||||
port: 4403,
|
||||
allowedHosts: true,
|
||||
fs: {
|
||||
allow: ['..']
|
||||
},
|
||||
proxy: {
|
||||
// Only proxy these two specific backend API paths
|
||||
'/auth/api': {
|
||||
target: 'http://localhost:4402',
|
||||
headers: { connection: 'close' }
|
||||
target: 'http://localhost:4402'
|
||||
},
|
||||
'/auth/ws': {
|
||||
target: 'http://localhost:4402',
|
||||
ws: true
|
||||
},
|
||||
// Passphrase links: /auth/word1.word2.word3.word4.word5
|
||||
'^/auth/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$': {
|
||||
target: 'http://localhost:4402'
|
||||
},
|
||||
// Passphrase links: /word1.word2.word3.word4.word5
|
||||
'^/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$': {
|
||||
target: 'http://localhost:4402'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -273,7 +273,7 @@ def main():
|
||||
}
|
||||
|
||||
# Dev mode: enable reload when PASKIA_DEVMODE is set
|
||||
devmode = os.environ.get("PASKIA_DEVMODE") == "1"
|
||||
devmode = bool(os.environ.get("PASKIA_DEVMODE"))
|
||||
if devmode:
|
||||
# Security: dev mode must run on localhost:4402 to prevent
|
||||
# accidental public exposure of the Vite dev server
|
||||
@@ -281,6 +281,8 @@ def main():
|
||||
raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}")
|
||||
run_kwargs["reload"] = True
|
||||
run_kwargs["reload_dirs"] = ["paskia"]
|
||||
# Suppress uvicorn startup messages in dev mode
|
||||
run_kwargs["log_level"] = "warning"
|
||||
|
||||
if uds:
|
||||
run_kwargs["uds"] = uds
|
||||
|
||||
+27
-1
@@ -200,11 +200,37 @@ 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,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/token-info")
|
||||
async def api_token_info(token: str):
|
||||
"""Get information about a reset token.
|
||||
|
||||
Returns:
|
||||
- type: "reset"
|
||||
- user_name: display name of the user
|
||||
- token_type: type of reset token
|
||||
"""
|
||||
if not passphrase.is_well_formed(token):
|
||||
raise HTTPException(status_code=404, detail="Invalid token")
|
||||
|
||||
# Check if this is a reset token
|
||||
try:
|
||||
reset_token = await get_reset(token)
|
||||
user = await db.instance.get_user_by_uuid(reset_token.user_uuid)
|
||||
return {
|
||||
"type": "reset",
|
||||
"user_name": user.display_name,
|
||||
"token_type": reset_token.token_type,
|
||||
}
|
||||
except (ValueError, Exception):
|
||||
raise HTTPException(status_code=404, detail="Token not found or expired")
|
||||
|
||||
|
||||
@app.post("/user-info")
|
||||
async def api_user_info(
|
||||
request: Request,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -42,8 +42,12 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
||||
# Re-raise to fail fast
|
||||
raise
|
||||
|
||||
# Restore info level logging after startup (suppressed during uvicorn init in dev mode)
|
||||
if frontend.is_dev_mode():
|
||||
logging.getLogger("uvicorn").setLevel(logging.INFO)
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.INFO)
|
||||
|
||||
yield
|
||||
# (Optional) add shutdown cleanup here later
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
@@ -113,10 +117,14 @@ async def examples_page():
|
||||
|
||||
|
||||
# Note: this catch-all handler must be the last route defined
|
||||
@app.get("/{reset}")
|
||||
@app.get("/auth/{reset}")
|
||||
async def reset_link(reset: str):
|
||||
"""Serve the reset app directly with an injected reset token."""
|
||||
if not passphrase.is_well_formed(reset):
|
||||
@app.get("/{token}")
|
||||
@app.get("/auth/{token}")
|
||||
async def token_link(token: str):
|
||||
"""Serve the reset app for reset tokens (password reset / device addition).
|
||||
|
||||
The frontend will validate the token via /auth/api/token-info.
|
||||
"""
|
||||
if not passphrase.is_well_formed(token):
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
return Response(*await frontend.read("/int/reset/index.html"))
|
||||
@@ -0,0 +1,504 @@
|
||||
"""
|
||||
Remote authentication WebSocket endpoints.
|
||||
|
||||
This module handles cross-device authentication where one device (requesting)
|
||||
wants to log in and another device (authenticating) provides the passkey.
|
||||
|
||||
Endpoints:
|
||||
- /request: Called by the device wanting to be authenticated
|
||||
- /pair: Called by the authenticating device to complete the request
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from uuid import UUID
|
||||
|
||||
import base64url
|
||||
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 validate_origin, websocket_error_handler
|
||||
from paskia.globals import db, passkey
|
||||
from paskia.util import passphrase, pow
|
||||
|
||||
# Create a FastAPI subapp for remote auth WebSocket endpoints
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.websocket("/request")
|
||||
@websocket_error_handler
|
||||
async def websocket_remote_auth_request(ws: WebSocket):
|
||||
"""Request authentication from another device.
|
||||
|
||||
This endpoint is called by the device that wants to be authenticated.
|
||||
It creates a remote auth request and waits for another device to authenticate.
|
||||
|
||||
Flow:
|
||||
1. Client connects
|
||||
2. Server sends HARD PoW challenge, client solves and responds
|
||||
3. Server creates a 3-word pairing code and sends it with expiry
|
||||
4. Server waits for another device to authenticate via /remote-auth/pair
|
||||
5. When auth completes, server sends session_token to this client
|
||||
6. Client can then use the session token to set a cookie
|
||||
7. Connection times out after 5 minutes with explicit timeout message
|
||||
"""
|
||||
origin = validate_origin(ws)
|
||||
host = origin.split("://", 1)[1]
|
||||
|
||||
if remoteauth.instance is None:
|
||||
raise ValueError("Remote authentication is not available")
|
||||
|
||||
# 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")
|
||||
|
||||
# Create the remote auth request
|
||||
pairing_code, expiry = await remoteauth.instance.create_request(
|
||||
host=host,
|
||||
ip=metadata.get("ip") or "",
|
||||
user_agent=metadata.get("user_agent") or "",
|
||||
action=action,
|
||||
)
|
||||
|
||||
# Send the pairing code to the client
|
||||
await ws.send_json(
|
||||
{
|
||||
"pairing_code": pairing_code,
|
||||
"expires": expiry.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
)
|
||||
|
||||
# Set up async notification for completion
|
||||
result_event = asyncio.Event()
|
||||
result_data: dict = {}
|
||||
|
||||
def on_complete(
|
||||
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(pairing_code, 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(
|
||||
pairing_code, 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. 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 events and websocket
|
||||
receive_task = asyncio.create_task(ws.receive_json())
|
||||
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(
|
||||
tasks,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
# Cancel pending tasks
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
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"]),
|
||||
}
|
||||
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:
|
||||
await ws.send_json(
|
||||
{
|
||||
"status": "expired",
|
||||
"detail": "Remote authentication request expired or was cancelled",
|
||||
}
|
||||
)
|
||||
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()
|
||||
if msg.get("action") == "cancel":
|
||||
await remoteauth.instance.cancel_request(pairing_code)
|
||||
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(
|
||||
pairing_code, new_action
|
||||
)
|
||||
# Ignore other messages
|
||||
|
||||
except TimeoutError:
|
||||
# 5 minute timeout reached
|
||||
await remoteauth.instance.cancel_request(pairing_code)
|
||||
await ws.send_json(
|
||||
{
|
||||
"status": "timeout",
|
||||
"detail": "Remote authentication request timed out after 5 minutes",
|
||||
}
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
# Client disconnected, cancel the request and mark as denied
|
||||
await remoteauth.instance.cancel_request(pairing_code, denied=True)
|
||||
except Exception:
|
||||
await remoteauth.instance.cancel_request(pairing_code)
|
||||
raise
|
||||
finally:
|
||||
# Decrement connection count
|
||||
remoteauth.instance.decrement_connections()
|
||||
|
||||
|
||||
@app.websocket("/pair")
|
||||
@websocket_error_handler
|
||||
async def websocket_remote_auth_pair(ws: WebSocket):
|
||||
"""Complete a remote authentication request using a 3-word pairing code.
|
||||
|
||||
This endpoint is called from the user's profile on the authenticating device.
|
||||
The user enters the pairing code displayed on the requesting device.
|
||||
|
||||
Protocol:
|
||||
1. Server sends PoW challenge immediately on connect
|
||||
2. Client sends {code: "word.word.word", pow: "<base64>"} for 3-word pairing code
|
||||
3. Server validates PoW and code:
|
||||
- If invalid code/PoW: {status: 4xx, detail: "...", pow: {challenge, work}}
|
||||
- If valid: {status: "found", host: "...", user_agent_pretty: "...", pow: {challenge, work}}
|
||||
4. Client can then send {authenticate: true} to start WebAuthn
|
||||
5. Server sends {optionsJSON: ...}
|
||||
6. Client sends WebAuthn response
|
||||
7. Server sends {status: "success", message: "..."}
|
||||
"""
|
||||
from paskia.util import useragent
|
||||
|
||||
origin = validate_origin(ws)
|
||||
|
||||
if remoteauth.instance is None:
|
||||
raise ValueError("Remote authentication is not available")
|
||||
|
||||
# Generate initial PoW challenge (always NORMAL for authenticated users)
|
||||
challenge = pow.generate_challenge()
|
||||
work = pow.NORMAL
|
||||
|
||||
await ws.send_json(
|
||||
{
|
||||
"pow": {
|
||||
"challenge": base64url.enc(challenge),
|
||||
"work": work,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
request = None
|
||||
webauthn_challenge = None
|
||||
explicitly_denied = False
|
||||
|
||||
try:
|
||||
while True:
|
||||
msg = await ws.receive_json()
|
||||
|
||||
# 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", "")
|
||||
|
||||
# Validate PoW for pairing codes
|
||||
solution_b64 = msg.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")
|
||||
|
||||
try:
|
||||
pow.verify_pow(challenge, solution, work)
|
||||
except ValueError as e:
|
||||
# Invalid PoW - send new challenge
|
||||
challenge = pow.generate_challenge()
|
||||
await ws.send_json(
|
||||
{
|
||||
"status": 400,
|
||||
"detail": str(e),
|
||||
"pow": {
|
||||
"challenge": base64url.enc(challenge),
|
||||
"work": work,
|
||||
},
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if not code:
|
||||
raise ValueError("Pairing code required")
|
||||
|
||||
# Look up the remote auth request by pairing code
|
||||
request = await remoteauth.instance.get_request(code)
|
||||
|
||||
# Generate new challenge for next request (always NORMAL for authenticated users)
|
||||
challenge = pow.generate_challenge()
|
||||
|
||||
if request is None:
|
||||
await ws.send_json(
|
||||
{
|
||||
"status": 404,
|
||||
"detail": "Code not found",
|
||||
"pow": {
|
||||
"challenge": base64url.enc(challenge),
|
||||
"work": work,
|
||||
},
|
||||
}
|
||||
)
|
||||
request = None # Reset for next attempt
|
||||
continue
|
||||
|
||||
# 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": 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
|
||||
+4
-44
@@ -1,59 +1,19 @@
|
||||
import logging
|
||||
from functools import wraps
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
|
||||
from fastapi import FastAPI, WebSocket
|
||||
|
||||
from paskia.authsession import create_session, get_reset, get_session
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
||||
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
||||
from paskia.globals import db, passkey
|
||||
from paskia.util import passphrase
|
||||
from paskia.util.tokens import create_token, session_key
|
||||
|
||||
|
||||
# WebSocket error handling decorator
|
||||
def websocket_error_handler(func):
|
||||
@wraps(func)
|
||||
async def wrapper(ws: WebSocket, *args, **kwargs):
|
||||
try:
|
||||
await ws.accept()
|
||||
return await func(ws, *args, **kwargs)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except authz.AuthException as e:
|
||||
await ws.send_json(
|
||||
{
|
||||
"status": e.status_code,
|
||||
**(await authz.auth_error_content(e)),
|
||||
}
|
||||
)
|
||||
except (ValueError, InvalidAuthenticationResponse) as e:
|
||||
await ws.send_json({"status": 401, "detail": str(e)})
|
||||
except Exception:
|
||||
logging.exception("Internal Server Error")
|
||||
await ws.send_json({"status": 500, "detail": "Internal Server Error"})
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
# Create a FastAPI subapp for WebSocket endpoints
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
def _validate_origin(ws: WebSocket) -> str:
|
||||
"""Extract and validate origin from WebSocket request headers.
|
||||
|
||||
Raises:
|
||||
ValueError: If origin header is missing or not in allowed list
|
||||
"""
|
||||
origin = ws.headers.get("origin")
|
||||
if not origin:
|
||||
raise ValueError("Origin header is required for WebSocket connections")
|
||||
return passkey.instance.validate_origin(origin)
|
||||
|
||||
|
||||
async def register_chat(
|
||||
ws: WebSocket,
|
||||
user_uuid: UUID,
|
||||
@@ -86,7 +46,7 @@ async def websocket_register_add(
|
||||
- Normal session via auth cookie (requires recent authentication)
|
||||
- Reset token supplied as ?reset=... (auth cookie ignored)
|
||||
"""
|
||||
origin = _validate_origin(ws)
|
||||
origin = validate_origin(ws)
|
||||
host = origin.split("://", 1)[1]
|
||||
if reset is not None:
|
||||
if not passphrase.is_well_formed(reset):
|
||||
@@ -142,7 +102,7 @@ async def websocket_register_add(
|
||||
@app.websocket("/authenticate")
|
||||
@websocket_error_handler
|
||||
async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
origin = _validate_origin(ws)
|
||||
origin = validate_origin(ws)
|
||||
host = origin.split("://", 1)[1]
|
||||
|
||||
# If there's an existing session, restrict to that user's credentials (reauth)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Shared WebSocket utilities for FastAPI endpoints.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from functools import wraps
|
||||
|
||||
import base64url
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
|
||||
|
||||
from paskia.fastapi import authz
|
||||
from paskia.globals import passkey
|
||||
from paskia.util import pow
|
||||
|
||||
|
||||
def websocket_error_handler(func):
|
||||
"""Decorator for WebSocket endpoints that handles common errors."""
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(ws: WebSocket, *args, **kwargs):
|
||||
try:
|
||||
await ws.accept()
|
||||
return await func(ws, *args, **kwargs)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except authz.AuthException as e:
|
||||
await ws.send_json(
|
||||
{
|
||||
"status": e.status_code,
|
||||
**(await authz.auth_error_content(e)),
|
||||
}
|
||||
)
|
||||
except (ValueError, InvalidAuthenticationResponse) as e:
|
||||
await ws.send_json({"status": 401, "detail": str(e)})
|
||||
except Exception:
|
||||
logging.exception("Internal Server Error")
|
||||
await ws.send_json({"status": 500, "detail": "Internal Server Error"})
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
async def require_pow(ws: WebSocket, work: int | None = None) -> None:
|
||||
"""Send a PoW challenge and verify the client's solution.
|
||||
|
||||
Sends: {"pow": {"challenge": "<base64>", "work": 10}}
|
||||
Expects: {"pow": "<base64-solution>"}
|
||||
|
||||
Args:
|
||||
ws: WebSocket connection
|
||||
work: PoW difficulty level (default: pow.DEFAULT_WORK)
|
||||
|
||||
Raises:
|
||||
ValueError: If the PoW solution is invalid
|
||||
"""
|
||||
challenge = pow.generate_challenge()
|
||||
if work is None:
|
||||
work = pow.DEFAULT_WORK
|
||||
|
||||
await ws.send_json(
|
||||
{
|
||||
"pow": {
|
||||
"challenge": base64url.enc(challenge),
|
||||
"work": work,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
response = await ws.receive_json()
|
||||
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)
|
||||
|
||||
|
||||
def validate_origin(ws: WebSocket) -> str:
|
||||
"""Extract and validate origin from WebSocket request headers.
|
||||
|
||||
Raises:
|
||||
ValueError: If origin header is missing or not in allowed list
|
||||
"""
|
||||
origin = ws.headers.get("origin")
|
||||
if not origin:
|
||||
raise ValueError("Origin header is required for WebSocket connections")
|
||||
return passkey.instance.validate_origin(origin)
|
||||
@@ -1,18 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Admin</title>
|
||||
<script type="module" crossorigin src="/auth/assets/admin-D8zxJOk4.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-R4vr2A9I.js">
|
||||
<link rel="modulepreload" crossorigin href="/auth/assets/helpers-CU0-cyzg.js">
|
||||
<link rel="modulepreload" crossorigin href="/auth/assets/AccessDenied-guOGfNm-.js">
|
||||
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-Bx2cFCEC.css">
|
||||
<link rel="stylesheet" crossorigin href="/auth/assets/AccessDenied-TAST_piX.css">
|
||||
<link rel="stylesheet" crossorigin href="/auth/assets/admin-DIOoLLHy.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="admin-app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
.breadcrumbs[data-v-2924b990]{margin:.25rem 0 .5rem;line-height:1.2;color:var(--color-text-muted)}.breadcrumbs ol[data-v-2924b990]{list-style:none;padding:0;margin:0;display:flex;flex-wrap:wrap;align-items:center;gap:.25rem}.breadcrumbs li[data-v-2924b990]{display:inline-flex;align-items:center;gap:.25rem;font-size:.9rem}.breadcrumbs a[data-v-2924b990]{text-decoration:none;color:var(--color-link);padding:0 .25rem;border-radius:4px;transition:color .2s ease,background .2s ease}.breadcrumbs a[data-v-2924b990]:hover,.breadcrumbs a[data-v-2924b990]:focus-visible{text-decoration:underline;color:var(--color-link-hover);outline:none}.breadcrumbs .sep[data-v-2924b990]{color:var(--color-text-muted);margin:0}.user-info[data-v-2994268a]{display:grid;grid-template-columns:auto 1fr;gap:10px}.user-info h3[data-v-2994268a]{grid-column:span 2}.org-role-sub[data-v-2994268a]{grid-column:span 2;display:flex;flex-direction:column;margin:-.15rem 0 .25rem}.org-line[data-v-2994268a]{font-size:.7rem;font-weight:600;line-height:1.1;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.05em}.role-line[data-v-2994268a]{font-size:.65rem;color:var(--color-text-muted);line-height:1.1}.user-info span[data-v-2994268a]{text-align:left}.user-name-heading[data-v-2994268a]{display:flex;align-items:center;gap:.4rem;flex-wrap:wrap;margin:0 0 .25rem}.user-name-row[data-v-2994268a]{display:inline-flex;align-items:center;gap:.35rem;max-width:100%}.user-name-row.editing[data-v-2994268a]{flex:1 1 auto}.icon[data-v-2994268a]{flex:0 0 auto}.display-name[data-v-2994268a]{font-weight:600;font-size:1.05em;line-height:1.2;max-width:14ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.name-input[data-v-2994268a]{width:auto;flex:1 1 140px;min-width:120px;padding:6px 8px;font-size:.9em;border:1px solid var(--color-border-strong);border-radius:6px;background:var(--color-surface);color:var(--color-text)}.user-name-heading .name-input[data-v-2994268a]{width:auto}.name-input[data-v-2994268a]:focus{outline:none;border-color:var(--color-accent);box-shadow:var(--focus-ring)}.mini-btn[data-v-2994268a]{width:auto;padding:4px 6px;margin:0;font-size:.75em;line-height:1;background:var(--color-surface-muted);border:1px solid var(--color-border-strong);border-radius:6px;cursor:pointer;transition:background .2s,transform .15s,color .2s ease;color:var(--color-text)}.mini-btn[data-v-2994268a]:hover:not(:disabled){background:var(--color-accent-soft);color:var(--color-accent)}.mini-btn[data-v-2994268a]:active:not(:disabled){transform:translateY(1px)}.mini-btn[data-v-2994268a]:disabled{opacity:.5;cursor:not-allowed}@media(max-width:480px){.user-name-heading[data-v-2994268a]{flex-direction:column;align-items:flex-start}.user-name-row.editing[data-v-2994268a]{width:100%}.display-name[data-v-2994268a]{max-width:100%}}.modal-overlay[data-v-d5e0cc32]{position:fixed;inset:0;background:transparent;backdrop-filter:blur(.1rem) brightness(.7);-webkit-backdrop-filter:blur(.1rem) brightness(.7);display:flex;align-items:center;justify-content:center;z-index:1000}.modal[data-v-d5e0cc32]{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-xl);padding:calc(var(--space-lg) - var(--space-xs));max-width:500px;width:min(500px,90vw);max-height:90vh;overflow-y:auto}.modal[data-v-d5e0cc32] .modal-title,.modal[data-v-d5e0cc32] h3{margin:0 0 var(--space-md);font-size:1.25rem;font-weight:600;color:var(--color-heading)}.modal[data-v-d5e0cc32] form,.modal[data-v-d5e0cc32] .modal-form{display:flex;flex-direction:column;gap:var(--space-md)}.modal[data-v-d5e0cc32] .modal-form label{display:flex;flex-direction:column;gap:var(--space-xs);font-weight:500}.modal[data-v-d5e0cc32] .modal-form input,.modal[data-v-d5e0cc32] .modal-form textarea{padding:var(--space-md);border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-bg);color:var(--color-text);font-size:1rem;line-height:1.4;min-height:2.5rem}.modal[data-v-d5e0cc32] .modal-form input:focus,.modal[data-v-d5e0cc32] .modal-form textarea:focus{outline:none;border-color:var(--color-accent);box-shadow:0 0 0 2px #c7d2fe}.modal[data-v-d5e0cc32] .modal-actions{display:flex;justify-content:flex-end;gap:var(--space-sm);margin-top:var(--space-md);margin-bottom:var(--space-xs)}.name-edit-form[data-v-944620d3]{display:flex;flex-direction:column;gap:var(--space-md)}.error[data-v-944620d3]{color:var(--color-danger-text)}.small[data-v-944620d3]{font-size:.9rem}.session-meta-info{grid-column:span 2}[data-component=session-list-section] .session-list{display:flex;flex-direction:column;gap:1.5em}.session-group{display:flex;flex-direction:column;gap:.5em}.session-group-host{font-size:1em;font-weight:600;margin:0}.session-group-host a{color:inherit;text-decoration:none}.session-group-host a:hover{text-decoration:underline}.session-group-host.is-current-site{color:var(--color-accent)}.session-group-sessions{display:grid;grid-template-columns:repeat(Line truncated
|
||||
@@ -1,8 +0,0 @@
|
||||
import{D as Bt,r as J,E as kt,G as Ht,H as ae,I as It,J as jt,K as zt,L as $t,B as ue,M as Kt,c as z,w as Nt,N as Jt,O as Yt,q as Y,C as Gt,P as Qt,Q as xt,d as N,i as U,u as F,e as R,f as h,t as P,n as re,_ as Q,F as ne,x as le,R as Wt,S as Rt,o as At,l as ce,z as Zt,T as Xt,k as ye,U as en,V as tn}from"./_plugin-vue_export-helper-R4vr2A9I.js";import{f as G,g as Ge}from"./helpers-CU0-cyzg.js";let Tt;const de=e=>Tt=e,Mt=Symbol();function He(e){return e&&typeof e=="object"&&Object.prototype.toString.call(e)==="[object Object]"&&typeof e.toJSON!="function"}var se;(function(e){e.direct="direct",e.patchObject="patch object",e.patchFunction="patch function"})(se||(se={}));function Sr(){const e=Bt(!0),r=e.run(()=>J({}));let o=[],t=[];const n=kt({install(s){de(n),n._a=s,s.provide(Mt,n),s.config.globalProperties.$pinia=n,t.forEach(i=>o.push(i)),t=[]},use(s){return this._a?o.push(s):t.push(s),this},_p:o,_a:null,_e:e,_s:new Map,state:r});return n}const Pt=()=>{};function Qe(e,r,o,t=Pt){e.add(r);const n=()=>{e.delete(r)&&t()};return!o&&zt()&&$t(n),n}function X(e,...r){e.forEach(o=>{o(...r)})}const nn=e=>e(),xe=Symbol(),we=Symbol();function je(e,r){e instanceof Map&&r instanceof Map?r.forEach((o,t)=>e.set(t,o)):e instanceof Set&&r instanceof Set&&r.forEach(e.add,e);for(const o in r){if(!r.hasOwnProperty(o))continue;const t=r[o],n=e[o];He(n)&&He(t)&&e.hasOwnProperty(o)&&!ae(t)&&!It(t)?e[o]=je(n,t):e[o]=t}return e}const sn=Symbol();function rn(e){return!He(e)||!Object.prototype.hasOwnProperty.call(e,sn)}const{assign:K}=Object;function on(e){return!!(ae(e)&&e.effect)}function an(e,r,o,t){const{state:n,actions:s,getters:i}=r,u=o.state.value[e];let a;function c(){u||(o.state.value[e]=n?n():{});const f=Kt(o.state.value[e]);return K(f,s,Object.keys(i||{}).reduce((y,w)=>(y[w]=kt(z(()=>{de(o);const d=o._s.get(e);return i[w].call(d,d)})),y),{}))}return a=Lt(e,c,r,o,t,!0),a}function Lt(e,r,o={},t,n,s){let i;const u=K({actions:{}},o),a={deep:!0};let c,f,y=new Set,w=new Set,d;const l=t.state.value[e];!s&&!l&&(t.state.value[e]={}),J({});let _;function L(m){let g;c=f=!1,typeof m=="function"?(m(t.state.value[e]),g={type:se.patchFunction,storeId:e,events:d}):(je(t.state.value[e],m),g={type:se.patchObject,payload:m,storeId:e,events:d});const v=_=Symbol();ue().then(()=>{_===v&&(c=!0)}),f=!0,X(y,g,t.state.value[e])}const I=s?function(){const{state:g}=o,v=g?g():{};this.$patch(b=>{K(b,v)})}:Pt;function p(){i.stop(),y.clear(),w.clear(),t._s.delete(e)}const C=(m,g="")=>{if(xe in m)return m[we]=g,m;const v=function(){de(t);const b=Array.from(arguments),B=new Set,k=new Set;function T(D){B.add(D)}function V(D){k.add(D)}X(w,{args:b,name:v[we],store:M,after:T,onError:V});let q;try{q=m.apply(this&&this.$id===e?this:M,b)}catch(D){throw X(k,D),D}return q instanceof Promise?q.then(D=>(X(B,D),D)).catch(D=>(X(k,D),Promise.reject(D))):(X(B,q),q)};return v[xe]=!0,v[we]=g,v},A={_p:t,$id:e,$onAction:Qe.bind(null,w),$patch:L,$reset:I,$subscribe(m,g={}){const v=Qe(y,m,g.detached,()=>b()),b=i.run(()=>Nt(()=>t.state.value[e],B=>{(g.flush==="sync"?f:c)&&m({storeId:e,type:se.direct,events:d},B)},K({},a,g)));return v},$dispose:p},M=Ht(A);t._s.set(e,M);const E=(t._a&&t._a.runWithContext||nn)(()=>t._e.run(()=>(i=Bt()).run(()=>r({action:C}))));for(const m in E){const g=E[m];if(ae(g)&&!on(g)||It(g))s||(l&&rn(g)&&(ae(g)?g.value=l[m]:je(g,l[m])),t.state.value[e][m]=g);else if(typeof g=="function"){const v=C(g,m);E[m]=v,u.actions[m]=g}}return K(M,E),K(jt(M),E),Object.defineProperty(M,"$state",{get:()=>t.state.value[e],set:m=>{L(g=>{K(g,m)})}}),t._p.forEach(m=>{K(M,i.run(()=>m({store:M,app:t._a,pinia:t,options:u})))}),l&&s&&o.hydrate&&o.hydrate(M.$state,l),c=!0,f=!0,M}function un(e,r,o){let t;const n=typeof r=="function";t=n?o:r;function s(i,u){const a=Yt();return i=i||(a?Jt(Mt,null):null),i&&de(i),i=Tt,i._s.has(e)||(n?Lt(e,r,t,i):an(e,t,i)),i._s.get(e)}return s.$id=e,s}const ze=un("auth",{state:()=>({userInfo:null,isLoading:!1,settings:null,currentView:"login",status:{message:"",type:"info",show:!1}}),getters:{},actions:{setLoading(e){this.isLoading=!!e},showMessage(e,r="info",o=3e3){this.status={message:e,type:r,show:!0},o>0&&setTimeout(()=>{this.status.show=!1},o)},async setSessionCookie(e){if(!e?.session_token)throw console.error("setSessionCookie called with missing session_token:",e),new Error("Authentication response missing session_token");return await Y("/auth/api/set-session",{method:"POST",headers:{Authorization:`Bearer ${e.session_token}`}})},async register(){this.isLoading=!0;try{const e=await xt();return await this.setSessionCookie(e),await this.loadUserInfo(),this.selectView(),e}finally{this.isLoading=!1}},async authenticate(){this.isLoading=!0;try{const e=await Qt();return await this.setSessionCookie(e),await this.loadUserInfo(),this.selectView(),e}finally{this.isLoading=!1}},selectView(){this.userInfo?this.currentView="profile":this.currentView="login"},async loadSettings(){this.settings=await Gt()},async loadUserInfo(){try{this.userInfo=await Y("/auth/aLine truncated
|
||||
]))+`;O.KANJI=new RegExp(o,"g"),O.BYTE_KANJI=new RegExp("[^A-Z0-9 $%*+\\-./:]+","g"),O.BYTE=new RegExp(t,"g"),O.NUMERIC=new RegExp(e,"g"),O.ALPHANUMERIC=new RegExp(r,"g");const n=new RegExp("^"+o+"$"),s=new RegExp("^"+e+"$"),i=new RegExp("^[A-Z0-9 $%*+\\-./:]+$");return O.testKanji=function(a){return n.test(a)},O.testNumeric=function(a){return s.test(a)},O.testAlphanumeric=function(a){return i.test(a)},O}var dt;function W(){return dt||(dt=1,(function(e){const r=Ut(),o=qt();e.NUMERIC={id:"Numeric",bit:1,ccBits:[10,12,14]},e.ALPHANUMERIC={id:"Alphanumeric",bit:2,ccBits:[9,11,13]},e.BYTE={id:"Byte",bit:4,ccBits:[8,16,16]},e.KANJI={id:"Kanji",bit:8,ccBits:[8,10,12]},e.MIXED={bit:-1},e.getCharCountIndicator=function(s,i){if(!s.ccBits)throw new Error("Invalid mode: "+s);if(!r.isValid(i))throw new Error("Invalid version: "+i);return i>=1&&i<10?s.ccBits[0]:i<27?s.ccBits[1]:s.ccBits[2]},e.getBestModeForData=function(s){return o.testNumeric(s)?e.NUMERIC:o.testAlphanumeric(s)?e.ALPHANUMERIC:o.testKanji(s)?e.KANJI:e.BYTE},e.toString=function(s){if(s&&s.id)return s.id;throw new Error("Invalid mode")},e.isValid=function(s){return s&&s.bit&&s.ccBits};function t(n){if(typeof n!="string")throw new Error("Param is not a string");switch(n.toLowerCase()){case"numeric":return e.NUMERIC;case"alphanumeric":return e.ALPHANUMERIC;case"kanji":return e.KANJI;case"byte":return e.BYTE;default:throw new Error("Unknown mode: "+n)}}e.from=function(s,i){if(e.isValid(s))return s;try{return t(s)}catch{return i}}})(Re)),Re}var ft;function Ns(){return ft||(ft=1,(function(e){const r=x(),o=Dt(),t=$e(),n=W(),s=Ut(),i=7973,u=r.getBCHDigit(i);function a(w,d,l){for(let _=1;_<=40;_++)if(d<=e.getCapacity(_,l,w))return _}function c(w,d){return n.getCharCountIndicator(w,d)+4}function f(w,d){let l=0;return w.forEach(function(_){const L=c(_.mode,d);l+=L+_.getBitsLength()}),l}function y(w,d){for(let l=1;l<=40;l++)if(f(w,l)<=e.getCapacity(l,d,n.MIXED))return l}e.from=function(d,l){return s.isValid(d)?parseInt(d,10):l},e.getCapacity=function(d,l,_){if(!s.isValid(d))throw new Error("Invalid QR Code version");typeof _>"u"&&(_=n.BYTE);const L=r.getSymbolTotalCodewords(d),I=o.getTotalCodewordsCount(d,l),p=(L-I)*8;if(_===n.MIXED)return p;const C=p-c(_,d);switch(_){case n.NUMERIC:return Math.floor(C/10*3);case n.ALPHANUMERIC:return Math.floor(C/11*2);case n.KANJI:return Math.floor(C/13);case n.BYTE:default:return Math.floor(C/8)}},e.getBestVersionForData=function(d,l){let _;const L=t.from(l,t.M);if(Array.isArray(d)){if(d.length>1)return y(d,L);if(d.length===0)return 1;_=d[0]}else _=d;return a(_.mode,_.getLength(),L)},e.getEncodedBits=function(d){if(!s.isValid(d)||d<7)throw new Error("Invalid QR Code version");let l=d<<12;for(;r.getBCHDigit(l)-u>=0;)l^=i<<r.getBCHDigit(l)-u;return d<<12|l}})(Ne)),Ne}var Te={},ht;function Rs(){if(ht)return Te;ht=1;const e=x(),r=1335,o=21522,t=e.getBCHDigit(r);return Te.getEncodedBits=function(s,i){const u=s.bit<<3|i;let a=u<<10;for(;e.getBCHDigit(a)-t>=0;)a^=r<<e.getBCHDigit(a)-t;return(u<<10|a)^o},Te}var Me={},Pe,gt;function As(){if(gt)return Pe;gt=1;const e=W();function r(o){this.mode=e.NUMERIC,this.data=o.toString()}return r.getBitsLength=function(t){return 10*Math.floor(t/3)+(t%3?t%3*3+1:0)},r.prototype.getLength=function(){return this.data.length},r.prototype.getBitsLength=function(){return r.getBitsLength(this.data.length)},r.prototype.write=function(t){let n,s,i;for(n=0;n+3<=this.data.length;n+=3)s=this.data.substr(n,3),i=parseInt(s,10),t.put(i,10);const u=this.data.length-n;u>0&&(s=this.data.substr(n),i=parseInt(s,10),t.put(i,u*3+1))},Pe=r,Pe}var Le,mt;function Ts(){if(mt)return Le;mt=1;const e=W(),r=["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z"," ","$","%","*","+","-",".","/",":"];function o(t){this.mode=e.ALPHANUMERIC,this.data=t}return o.getBitsLength=function(n){return 11*Math.floor(n/2)+6*(n%2)},o.prototype.getLength=function(){return this.data.length},o.prototype.getBitsLength=function(){return o.getBitsLength(this.data.length)},o.prototype.write=function(n){let s;for(s=0;s+2<=this.data.length;s+=2){let i=r.indexOf(this.data[s])*45;i+=r.indexOf(this.data[s+1]),n.put(i,11)}this.data.length%2&&n.put(r.indexOf(this.data[s]),6)},Le=o,Le}var De,yt;function Ms(){if(yt)return De;yt=1;const e=W();function r(o){this.mode=e.BYTE,typeof o=="string"?this.data=new TextEncoder().encode(o):this.data=new Uint8Array(o)}return r.getBitsLength=function(t){return t*8},r.prototype.getLength=function(){return this.data.length},r.prototype.getBitsLength=function(){return r.getBitsLength(this.data.length)},r.prototype.write=function(o){for(let t=0,n=this.data.length;t<n;t++)o.put(this.data[t],8)},De=r,De}var Ue,wt;function Ps(){if(wt)return Ue;wt=1;const e=W(),r=x();function o(t){this.mode=e.KANJI,this.data=t}return o.getBitsLength=function(n){return n*13},o.prototype.getLength=function(){return this.data.length},o.prototype.getBitsLength=funct
Line truncated
|
||||
Make sure your charset is UTF-8`);s=(s>>>8&255)*192+(s&255),t.put(s,13)}},Ue=o,Ue}var qe={exports:{}},pt;function Ls(){return pt||(pt=1,(function(e){var r={single_source_shortest_paths:function(o,t,n){var s={},i={};i[t]=0;var u=r.PriorityQueue.make();u.push(t,0);for(var a,c,f,y,w,d,l,_,L;!u.empty();){a=u.pop(),c=a.value,y=a.cost,w=o[c]||{};for(f in w)w.hasOwnProperty(f)&&(d=w[f],l=y+d,_=i[f],L=typeof i[f]>"u",(L||_>l)&&(i[f]=l,u.push(f,l),s[f]=c))}if(typeof n<"u"&&typeof i[n]>"u"){var I=["Could not find a path from ",t," to ",n,"."].join("");throw new Error(I)}return s},extract_shortest_path_from_predecessor_list:function(o,t){for(var n=[],s=t;s;)n.push(s),o[s],s=o[s];return n.reverse(),n},find_path:function(o,t,n){var s=r.single_source_shortest_paths(o,t,n);return r.extract_shortest_path_from_predecessor_list(s,n)},PriorityQueue:{make:function(o){var t=r.PriorityQueue,n={},s;o=o||{};for(s in t)t.hasOwnProperty(s)&&(n[s]=t[s]);return n.queue=[],n.sorter=o.sorter||t.default_sorter,n},default_sorter:function(o,t){return o.cost-t.cost},push:function(o,t){var n={value:o,cost:t};this.queue.push(n),this.queue.sort(this.sorter)},pop:function(){return this.queue.shift()},empty:function(){return this.queue.length===0}}};e.exports=r})(qe)),qe.exports}var vt;function Ds(){return vt||(vt=1,(function(e){const r=W(),o=As(),t=Ts(),n=Ms(),s=Ps(),i=qt(),u=x(),a=Ls();function c(I){return unescape(encodeURIComponent(I)).length}function f(I,p,C){const A=[];let M;for(;(M=I.exec(C))!==null;)A.push({data:M[0],index:M.index,mode:p,length:M[0].length});return A}function y(I){const p=f(i.NUMERIC,r.NUMERIC,I),C=f(i.ALPHANUMERIC,r.ALPHANUMERIC,I);let A,M;return u.isKanjiModeEnabled()?(A=f(i.BYTE,r.BYTE,I),M=f(i.KANJI,r.KANJI,I)):(A=f(i.BYTE_KANJI,r.BYTE,I),M=[]),p.concat(C,A,M).sort(function(E,m){return E.index-m.index}).map(function(E){return{data:E.data,mode:E.mode,length:E.length}})}function w(I,p){switch(p){case r.NUMERIC:return o.getBitsLength(I);case r.ALPHANUMERIC:return t.getBitsLength(I);case r.KANJI:return s.getBitsLength(I);case r.BYTE:return n.getBitsLength(I)}}function d(I){return I.reduce(function(p,C){const A=p.length-1>=0?p[p.length-1]:null;return A&&A.mode===C.mode?(p[p.length-1].data+=C.data,p):(p.push(C),p)},[])}function l(I){const p=[];for(let C=0;C<I.length;C++){const A=I[C];switch(A.mode){case r.NUMERIC:p.push([A,{data:A.data,mode:r.ALPHANUMERIC,length:A.length},{data:A.data,mode:r.BYTE,length:A.length}]);break;case r.ALPHANUMERIC:p.push([A,{data:A.data,mode:r.BYTE,length:A.length}]);break;case r.KANJI:p.push([A,{data:A.data,mode:r.BYTE,length:c(A.data)}]);break;case r.BYTE:p.push([{data:A.data,mode:r.BYTE,length:c(A.data)}])}}return p}function _(I,p){const C={},A={start:{}};let M=["start"];for(let S=0;S<I.length;S++){const E=I[S],m=[];for(let g=0;g<E.length;g++){const v=E[g],b=""+S+g;m.push(b),C[b]={node:v,lastCount:0},A[b]={};for(let B=0;B<M.length;B++){const k=M[B];C[k]&&C[k].node.mode===v.mode?(A[k][b]=w(C[k].lastCount+v.length,v.mode)-w(C[k].lastCount,v.mode),C[k].lastCount+=v.length):(C[k]&&(C[k].lastCount=v.length),A[k][b]=w(v.length,v.mode)+4+r.getCharCountIndicator(v.mode,p))}}M=m}for(let S=0;S<M.length;S++)A[M[S]].end=0;return{map:A,table:C}}function L(I,p){let C;const A=r.getBestModeForData(I);if(C=r.from(p,A),C!==r.BYTE&&C.bit<A.bit)throw new Error('"'+I+'" cannot be encoded with mode '+r.toString(C)+`.
|
||||
Suggested mode is: `+r.toString(A));switch(C===r.KANJI&&!u.isKanjiModeEnabled()&&(C=r.BYTE),C){case r.NUMERIC:return new o(I);case r.ALPHANUMERIC:return new t(I);case r.KANJI:return new s(I);case r.BYTE:return new n(I)}}e.fromArray=function(p){return p.reduce(function(C,A){return typeof A=="string"?C.push(L(A,null)):A.data&&C.push(L(A.data,A.mode)),C},[])},e.fromString=function(p,C){const A=y(p,u.isKanjiModeEnabled()),M=l(A),S=_(M,C),E=a.find_path(S.map,"start","end"),m=[];for(let g=1;g<E.length-1;g++)m.push(S.table[E[g]].node);return e.fromArray(d(m))},e.rawSplit=function(p){return e.fromArray(y(p,u.isKanjiModeEnabled()))}})(Me)),Me}var Ct;function Us(){if(Ct)return ve;Ct=1;const e=x(),r=$e(),o=Cs(),t=bs(),n=_s(),s=Ss(),i=Es(),u=Dt(),a=Is(),c=Ns(),f=Rs(),y=W(),w=Ds();function d(S,E){const m=S.size,g=s.getPositions(E);for(let v=0;v<g.length;v++){const b=g[v][0],B=g[v][1];for(let k=-1;k<=7;k++)if(!(b+k<=-1||m<=b+k))for(let T=-1;T<=7;T++)B+T<=-1||m<=B+T||(k>=0&&k<=6&&(T===0||T===6)||T>=0&&T<=6&&(k===0||k===6)||k>=2&&k<=4&&T>=2&&T<=4?S.set(b+k,B+T,!0,!0):S.set(b+k,B+T,!1,!0))}}function l(S){const E=S.size;for(let m=8;m<E-8;m++){const g=m%2===0;S.set(m,6,g,!0),S.set(6,m,g,!0)}}function _(S,E){const m=n.getPositions(E);for(let g=0;g<m.length;g++){const v=m[g][0],b=m[g][1];for(let B=-2;B<=2;B++)for(let k=-2;k<=2;k++)B===-2||B===2||k===-2||k===2||B===0&&k===0?S.set(v+B,b+k,!0,!0):S.set(v+B,b+k,!1,!0)}}function L(S,E){const m=S.size,g=c.getEncodedBits(E);let v,b,B;for(let k=0;k<18;k++)v=Math.floor(k/3),b=k%3+m-8-3,B=(g>>k&1)===1,S.set(v,b,B,!0),S.set(b,v,B,!0)}function I(S,E,m){const g=S.size,v=f.getEncodedBits(E,m);let b,B;for(b=0;b<15;b++)B=(v>>b&1)===1,b<6?S.set(b,8,B,!0):b<8?S.set(b+1,8,B,!0):S.set(g-15+b,8,B,!0),b<8?S.set(8,g-b-1,B,!0):b<9?S.set(8,15-b-1+1,B,!0):S.set(8,15-b-1,B,!0);S.set(g-8,8,1,!0)}function p(S,E){const m=S.size;let g=-1,v=m-1,b=7,B=0;for(let k=m-1;k>0;k-=2)for(k===6&&k--;;){for(let T=0;T<2;T++)if(!S.isReserved(v,k-T)){let V=!1;B<E.length&&(V=(E[B]>>>b&1)===1),S.set(v,k-T,V),b--,b===-1&&(B++,b=7)}if(v+=g,v<0||m<=v){v-=g,g=-g;break}}}function C(S,E,m){const g=new o;m.forEach(function(T){g.put(T.mode.bit,4),g.put(T.getLength(),y.getCharCountIndicator(T.mode,S)),T.write(g)});const v=e.getSymbolTotalCodewords(S),b=u.getTotalCodewordsCount(S,E),B=(v-b)*8;for(g.getLengthInBits()+4<=B&&g.put(0,4);g.getLengthInBits()%8!==0;)g.putBit(0);const k=(B-g.getLengthInBits())/8;for(let T=0;T<k;T++)g.put(T%2?17:236,8);return A(g,S,E)}function A(S,E,m){const g=e.getSymbolTotalCodewords(E),v=u.getTotalCodewordsCount(E,m),b=g-v,B=u.getBlocksCount(E,m),k=g%B,T=B-k,V=Math.floor(g/B),q=Math.floor(b/B),D=q+1,Ke=V-q,Vt=new a(Ke);let fe=0;const oe=new Array(B),Je=new Array(B);let he=0;const Ot=new Uint8Array(S.buffer);for(let Z=0;Z<B;Z++){const me=Z<T?q:D;oe[Z]=Ot.slice(fe,fe+me),Je[Z]=Vt.encode(oe[Z]),fe+=me,he=Math.max(he,me)}const ge=new Uint8Array(g);let Ye=0,H,j;for(H=0;H<he;H++)for(j=0;j<B;j++)H<oe[j].length&&(ge[Ye++]=oe[j][H]);for(H=0;H<Ke;H++)for(j=0;j<B;j++)ge[Ye++]=Je[j][H];return ge}function M(S,E,m,g){let v;if(Array.isArray(S))v=w.fromArray(S);else if(typeof S=="string"){let V=E;if(!V){const q=w.rawSplit(S);V=c.getBestVersionForData(q,m)}v=w.fromString(S,V||40)}else throw new Error("Invalid data");const b=c.getBestVersionForData(v,m);if(!b)throw new Error("The amount of data is too big to be stored in a QR Code");if(!E)E=b;else if(E<b)throw new Error(`
|
||||
The chosen QR Code version cannot contain this amount of data.
|
||||
Minimum version required to store current data is: `+b+`.
|
||||
`);const B=C(E,m,v),k=e.getSymbolSize(E),T=new t(k);return d(T,E),l(T),_(T,E),I(T,m,0),E>=7&&L(T,E),p(T,B),isNaN(g)&&(g=i.getBestMask(T,I.bind(null,T,m))),i.applyMask(g,T),I(T,m,g),{modules:T,version:E,errorCorrectionLevel:m,maskPattern:g,segments:v}}return ve.create=function(E,m){if(typeof E>"u"||E==="")throw new Error("No input text");let g=r.M,v,b;return typeof m<"u"&&(g=r.from(m.errorCorrectionLevel,r.M),v=c.from(m.version),b=i.from(m.maskPattern),m.toSJISFunc&&e.setToSJISFunction(m.toSJISFunc)),M(E,v,g,b)},ve}var Fe={},Ve={},bt;function Ft(){return bt||(bt=1,(function(e){function r(o){if(typeof o=="number"&&(o=o.toString()),typeof o!="string")throw new Error("Color should be defined as hex string");let t=o.slice().replace("#","").split("");if(t.length<3||t.length===5||t.length>8)throw new Error("Invalid hex color: "+o);(t.length===3||t.length===4)&&(t=Array.prototype.concat.apply([],t.map(function(s){return[s,s]}))),t.length===6&&t.push("F","F");const n=parseInt(t.join(""),16);return{r:n>>24&255,g:n>>16&255,b:n>>8&255,a:n&255,hex:"#"+t.slice(0,6).join("")}}e.getOptions=function(t){t||(t={}),t.color||(t.color={});const n=typeof t.margin>"u"||t.margin===null||t.margin<0?4:t.margin,s=t.width&&t.width>=21?t.width:void 0,i=t.scale||4;return{width:s,scale:s?4:i,margin:n,color:{dark:r(t.color.dark||"#000000ff"),light:r(t.color.light||"#ffffffff")},type:t.type,rendererOpts:t.rendererOpts||{}}},e.getScale=function(t,n){return n.width&&n.width>=t+n.margin*2?n.width/(t+n.margin*2):n.scale},e.getImageWidth=function(t,n){const s=e.getScale(t,n);return Math.floor((t+n.margin*2)*s)},e.qrToImageData=function(t,n,s){const i=n.modules.size,u=n.modules.data,a=e.getScale(i,s),c=Math.floor((i+s.margin*2)*a),f=s.margin*a,y=[s.color.light,s.color.dark];for(let w=0;w<c;w++)for(let d=0;d<c;d++){let l=(w*c+d)*4,_=s.color.light;if(w>=f&&d>=f&&w<c-f&&d<c-f){const L=Math.floor((w-f)/a),I=Math.floor((d-f)/a);_=y[u[L*i+I]?1:0]}t[l++]=_.r,t[l++]=_.g,t[l++]=_.b,t[l]=_.a}}})(Ve)),Ve}var _t;function qs(){return _t||(_t=1,(function(e){const r=Ft();function o(n,s,i){n.clearRect(0,0,s.width,s.height),s.style||(s.style={}),s.height=i,s.width=i,s.style.height=i+"px",s.style.width=i+"px"}function t(){try{return document.createElement("canvas")}catch{throw new Error("You need to specify a canvas element")}}e.render=function(s,i,u){let a=u,c=i;typeof a>"u"&&(!i||!i.getContext)&&(a=i,i=void 0),i||(c=t()),a=r.getOptions(a);const f=r.getImageWidth(s.modules.size,a),y=c.getContext("2d"),w=y.createImageData(f,f);return r.qrToImageData(w.data,s,a),o(y,c,f),y.putImageData(w,0,0),c},e.renderToDataURL=function(s,i,u){let a=u;typeof a>"u"&&(!i||!i.getContext)&&(a=i,i=void 0),a||(a={});const c=e.render(s,i,a),f=a.type||"image/png",y=a.rendererOpts||{};return c.toDataURL(f,y.quality)}})(Fe)),Fe}var Oe={},St;function Fs(){if(St)return Oe;St=1;const e=Ft();function r(n,s){const i=n.a/255,u=s+'="'+n.hex+'"';return i<1?u+" "+s+'-opacity="'+i.toFixed(2).slice(1)+'"':u}function o(n,s,i){let u=n+s;return typeof i<"u"&&(u+=" "+i),u}function t(n,s,i){let u="",a=0,c=!1,f=0;for(let y=0;y<n.length;y++){const w=Math.floor(y%s),d=Math.floor(y/s);!w&&!c&&(c=!0),n[y]?(f++,y>0&&w>0&&n[y-1]||(u+=c?o("M",w+i,.5+d+i):o("m",a,0),a=0,c=!1),w+1<s&&n[y+1]||(u+=o("h",f),f=0)):a++}return u}return Oe.render=function(s,i,u){const a=e.getOptions(i),c=s.modules.size,f=s.modules.data,y=c+a.margin*2,w=a.color.light.a?"<path "+r(a.color.light,"fill")+' d="M0 0h'+y+"v"+y+'H0z"/>':"",d="<path "+r(a.color.dark,"stroke")+' d="'+t(f,c,a.margin)+'"/>',l='viewBox="0 0 '+y+" "+y+'"',L='<svg xmlns="http://www.w3.org/2000/svg" '+(a.width?'width="'+a.width+'" height="'+a.width+'" ':"")+l+' shape-rendering="crispEdges">'+w+d+`</svg>
|
||||
`;return typeof u=="function"&&u(null,L),L},Oe}var Et;function Vs(){if(Et)return ee;Et=1;const e=vs(),r=Us(),o=qs(),t=Fs();function n(s,i,u,a,c){const f=[].slice.call(arguments,1),y=f.length,w=typeof f[y-1]=="function";if(!w&&!e())throw new Error("Callback required as last argument");if(w){if(y<2)throw new Error("Too few arguments provided");y===2?(c=u,u=i,i=a=void 0):y===3&&(i.getContext&&typeof c>"u"?(c=a,a=void 0):(c=a,a=u,u=i,i=void 0))}else{if(y<1)throw new Error("Too few arguments provided");return y===1?(u=i,i=a=void 0):y===2&&!i.getContext&&(a=u,u=i,i=void 0),new Promise(function(d,l){try{const _=r.create(u,a);d(s(_,i,a))}catch(_){l(_)}})}try{const d=r.create(u,a);c(null,s(d,i,a))}catch(d){c(d)}}return ee.create=r.create,ee.toCanvas=n.bind(null,o.render),ee.toDataURL=n.bind(null,o.renderToDataURL),ee.toString=n.bind(null,function(s,i,u){return t.render(s,u)}),ee}var Os=Vs();const Hs=ps(Os),js={class:"device-dialog",role:"dialog","aria-modal":"true","aria-labelledby":"regTitle"},zs={class:"reg-header-row"},$s={id:"regTitle",class:"reg-title"},Ks={key:0},Js={key:1},Ys={class:"device-link-section"},Gs={class:"qr-container"},Qs=["href"],xs={class:"reg-help"},Ws={key:0},Zs={key:1},Xs={class:"reg-actions"},er={key:1,class:"registration-inline-wrapper"},tr={class:"registration-inline-block section-block"},nr={class:"section-header"},sr={class:"inline-heading"},rr={key:0},or={key:1},ir={class:"section-body"},ar={class:"device-link-section"},ur={class:"qr-container"},lr=["href"],cr={class:"reg-help"},dr={key:0},fr={key:1},hr={class:"button-row",style:{"margin-top":"1rem"}},gr={__name:"RegistrationLinkModal",props:{endpoint:{type:String,required:!0},autoCopy:{type:Boolean,default:!0},userName:{type:String,default:null},inline:{type:Boolean,default:!1},showCloseInInline:{type:Boolean,default:!1},prefixCopyWithUserName:{type:Boolean,default:!1}},emits:["close","generated","copied"],setup(e,{emit:r}){const o=ze(),t=e,n=r,s=J(null),i=J(null),u=J(null),a=z(()=>s.value?s.value.replace(/^[^:]+:\/\//,""):""),c=z(()=>{const d=G(i.value);return`⚠️ Expires ${d.startsWith("In ")?d.substring(3):d} and can only be used once.`});async function f(){try{const d=await Y(t.endpoint,{method:"POST"});s.value=d.url,i.value=d.expires,n("generated",{url:d.url,expires:d.expires}),await ue(),y(),t.autoCopy&&w()}catch(d){console.error("Failed to create link",d),en(d)&&o.showMessage(tn(d),"error",4e3),n("close")}}async function y(){s.value&&(await ue(),u.value&&Hs.toCanvas(u.value,s.value,{scale:8},d=>{d&&console.error(d)}))}async function w(){if(!s.value)return;let d=s.value;t.prefixCopyWithUserName&&t.userName&&(d=`${t.userName} ${d}`);try{await navigator.clipboard.writeText(d),n("copied",d),t.inline||n("close")}catch{}}return At(f),Nt(s,()=>y(),{flush:"post"}),(d,l)=>!e.inline&&s.value?(R(),N("div",{key:0,class:"dialog-overlay",onKeydown:l[2]||(l[2]=Rt(ye(_=>d.$emit("close"),["prevent"]),["esc"]))},[h("div",js,[h("div",zs,[h("h2",$s,[l[4]||(l[4]=ce(" 📱 ",-1)),e.userName?(R(),N("span",Ks,"Registration for "+P(e.userName),1)):(R(),N("span",Js,"Device Registration Link"))]),h("button",{class:"icon-btn",onClick:l[0]||(l[0]=_=>d.$emit("close")),"aria-label":"Close"},"❌")]),h("div",Ys,[h("div",Gs,[h("a",{href:s.value,onClick:ye(w,["prevent"]),class:"qr-link"},[h("canvas",{ref_key:"qrCanvas",ref:u,class:"qr-code"},null,512),h("p",null,P(a.value),1)],8,Qs),h("p",xs,[e.userName?(R(),N("span",Ws,"The user should open this link on the device where they want to register.")):(R(),N("span",Zs,"Open or scan this link on the device you wish to register to your account.")),l[5]||(l[5]=h("br",null,null,-1)),h("small",null,P(c.value),1)])])]),h("div",Xs,[h("button",{class:"btn-secondary",onClick:l[1]||(l[1]=_=>d.$emit("close"))},"Close"),h("button",{class:"btn-primary",onClick:w},"Copy Link")])])],32)):e.inline&&s.value?(R(),N("div",er,[h("div",tr,[h("div",nr,[h("h2",sr,[l[6]||(l[6]=ce("📱 ",-1)),e.userName?(R(),N("span",rr,"Registration for "+P(e.userName),1)):(R(),N("span",or,"Device Registration Link"))])]),h("div",ir,[h("div",ar,[h("div",ur,[h("a",{href:s.value,onClick:ye(w,["prevent"]),class:"qr-link"},[h("canvas",{ref_key:"qrCanvas",ref:u,class:"qr-code"},null,512),h("p",null,P(a.value),1)],8,lr),h("p",cr,[e.userName?(R(),N("span",dr,"The user should open this link on the device where they want to register.")):(R(),N("span",fr,"Open this link on the device you wish to connect with.")),l[7]||(l[7]=h("br",null,null,-1)),h("small",null,P(c.value),1)])])]),h("div",hr,[h("button",{class:"btn-primary",onClick:w},"Copy Link"),e.showCloseInInline?(R(),N("button",{key:0,class:"btn-secondary",onClick:l[3]||(l[3]=_=>d.$emit("close"))},"Close")):U("",!0)])])])])):U("",!0)}},Tr=Q(gr,[["__scopeId","data-v-5d6bcbcb"]]),mr={class:"loading-container"},yr={__name:"LoadingView",props:{message:{type:String,default:"Loading..."}},setup(e){return(r,o)=>(R(),N("div",mr,[o[0]||(o[0]=h("div",{class:"loading-spinner"},null,-1)),h("p",null,P(e.message),1)]))}},
Line truncated
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as z,G as E,r as g,c as y,o as L,d,e as h,i as f,f as n,t as _,n as N,R as O,C as Y,Y as w,V as T,p as D}from"./_plugin-vue_export-helper-R4vr2A9I.js";const q={class:"app-shell"},G={key:0,class:"global-status",style:{display:"block"}},J={class:"view-root"},W={key:0,class:"surface surface--tight"},j={class:"view-header center"},H={key:0,class:"user-line"},K={class:"view-lede"},Q={class:"section-block"},X={class:"section-body center"},Z={class:"button-row center"},ee=["disabled"],te=["disabled"],ae=["disabled"],se=["disabled"],ne={__name:"RestrictedAuth",props:{mode:{type:String,default:"login",validator:o=>["login","reauth","forbidden"].includes(o)}},emits:["authenticated","forbidden","logout","back","home","auth-error"],setup(o,{expose:V,emit:$}){const v=o,m=$,i=E({show:!1,message:"",type:"info"}),b=g(!0),t=g(!1),S=g(null),r=g(null),l=g("initial");let p=null;const u=y(()=>!!r.value?.authenticated),k=y(()=>b.value?!1:v.mode==="reauth"?!0:l.value!=="forbidden"),U=y(()=>v.mode==="reauth"?"🔐 Additional Authentication":l.value==="forbidden"?"🚫 Forbidden":`🔐 ${S.value?.rp_name||location.origin}`),B=y(()=>v.mode==="reauth"?"Please verify your identity to continue with this action.":l.value==="forbidden"?"You lack the required permissions.":"Please sign in with your passkey."),F=y(()=>r.value?.user?.user_name||"User");function c(e,a="info",s=3e3){i.show=!0,i.message=e,i.type=a,p&&clearTimeout(p),s>0&&(p=setTimeout(()=>{i.show=!1},s))}async function I(){try{const e=await Y();if(S.value=e,e?.rp_name){const a=v.mode==="reauth"?"Verify Identity":u.value?"Forbidden":"Sign In";document.title=`${e.rp_name} · ${a}`}}catch(e){console.warn("Unable to load settings",e)}}async function M(){try{r.value=await w("/auth/api/user-info",{method:"POST"}),u.value&&v.mode!=="reauth"?(l.value="forbidden",m("forbidden",r.value)):l.value="login"}catch(e){console.error("Failed to load user info",e),e.status!==401&&e.status!==403&&c(T(e),"error",4e3),r.value=null,l.value="login"}}async function A(){if(!k.value||t.value)return;t.value=!0,c("Starting authentication…","info");let e;try{e=await D.authenticate()}catch(a){t.value=!1;const s=a?.message||"Passkey authentication cancelled",P=s==="Passkey authentication cancelled";c(s,P?"info":"error",4e3),m("auth-error",{message:s,cancelled:P});return}try{await x(e)}catch(a){t.value=!1;const s=a?.message||"Failed to establish session";c(s,"error",4e3),m("auth-error",{message:s,cancelled:!1});return}t.value=!1,m("authenticated",e)}async function C(){if(!t.value){t.value=!0;try{await w("/auth/api/logout",{method:"POST"}),r.value=null,l.value="login",c("Logged out. You can sign in with a different account.","info",3e3)}catch(e){c(T(e),"error",4e3)}finally{t.value=!1}m("logout")}}function R(){const e=window.open("/auth/","passkey_auth_profile");e&&e.focus()}async function x(e){if(!e?.session_token)throw console.error("setSessionCookie called with missing session_token:",e),new Error("Authentication response missing session_token");return await w("/auth/api/set-session",{method:"POST",headers:{Authorization:`Bearer ${e.session_token}`}})}return L(async()=>{await I(),await M(),b.value=!1}),V({showMessage:c,isAuthenticated:u,userInfo:r}),(e,a)=>(h(),d("div",q,[i.show?(h(),d("div",G,[n("div",{class:N(["status",i.type])},_(i.message),3)])):f("",!0),n("main",J,[b.value?f("",!0):(h(),d("div",W,[n("header",j,[n("h1",null,_(U.value),1),u.value?(h(),d("p",H,"👤 "+_(F.value),1)):f("",!0),n("p",K,_(B.value),1)]),n("section",Q,[n("div",X,[n("div",Z,[O(e.$slots,"actions",{loading:t.value,canAuthenticate:k.value,isAuthenticated:u.value,authenticate:A,logout:C,mode:o.mode},()=>[n("button",{class:"btn-secondary",disabled:t.value,onClick:a[0]||(a[0]=s=>e.$emit("back"))},"Back",8,ee),k.value?(h(),d("button",{key:0,class:"btn-primary",disabled:t.value,onClick:A},_(t.value?o.mode==="reauth"?"Verifying…":"Signing in…":o.mode==="reauth"?"Verify":"Login"),9,te)):f("",!0),u.value&&o.mode!=="reauth"?(h(),d("button",{key:1,class:"btn-danger",disabled:t.value,onClick:C},"Logout",8,ae)):f("",!0),u.value&&o.mode!=="reauth"?(h(),d("button",{key:2,class:"btn-primary",disabled:t.value,onClick:R},"Profile",8,se)):f("",!0)])])])])]))])]))}},ie=z(ne,[["__scopeId","data-v-d00079a6"]]);export{ie as R};
|
||||
@@ -1 +0,0 @@
|
||||
.button-row.center[data-v-d00079a6]{display:flex;justify-content:center;gap:.75rem}.user-line[data-v-d00079a6]{margin:.5rem 0 0;font-weight:500;color:var(--color-text)}main.view-root[data-v-d00079a6]{min-height:100vh;align-items:center;justify-content:center;padding:2rem 1rem}.surface.surface--tight[data-v-d00079a6]{max-width:520px;margin:0 auto;width:100%;display:flex;flex-direction:column;gap:1.75rem}
|
||||
@@ -1 +0,0 @@
|
||||
:root{--font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;--font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;--color-canvas: #f5f6f8;--color-surface: #ffffff;--color-surface-subtle: #f1f3f7;--color-border: #d0d5dd;--color-border-strong: #9aa2af;--color-heading: #101828;--color-text: #1f2933;--color-text-muted: #52616b;--color-link: #2563eb;--color-link-hover: #1d4ed8;--color-accent: #2563eb;--color-accent-strong: #1e3faa;--color-accent-contrast: #ffffff;--color-success-text: #0f5132;--color-success-bg: #d1fadf;--color-error-text: #b42318;--color-error-bg: #ffe3e3;--color-info-text: #0f609b;--color-info-bg: #d6ecff;--color-danger: #dc2626;--shadow-soft: 0 10px 30px rgba(15, 23, 42, .08);--radius-none: 0;--radius-sm: 4px;--radius-md: 6px;--radius-lg: 10px;--space-xxs: .25rem;--space-xs: .5rem;--space-sm: .75rem;--space-md: 1rem;--space-lg: 1.5rem;--space-xl: 2.25rem;--space-xxl: 3.5rem;--layout-max-width: 1400px;--layout-padding: clamp(1.5rem, 3vw + 1rem, 3.25rem);--transition-base: .16s ease}@media(prefers-color-scheme:dark){:root{--color-canvas: #0f172a;--color-surface: #141b2f;--color-surface-subtle: #1b243b;--color-border: #25304a;--color-border-strong: #3d4d6b;--color-heading: #f8fafc;--color-text: #e2e8f0;--color-text-muted: #94a3b8;--color-link: #60a5fa;--color-link-hover: #93c5fd;--color-accent: #60a5fa;--color-accent-strong: #3b82f6;--color-accent-contrast: #0b1120;--color-success-text: #34d399;--color-success-bg: #1a4d2e;--color-error-text: #fca5a5;--color-error-bg: #4a1f1f;--color-info-text: #bae6fd;--color-info-bg: #1e3a5f;--color-danger: #f87171;--shadow-soft: 0 0 0 #000000}}*,*:before,*:after{box-sizing:border-box}html{height:100%;background:var(--color-canvas)}body{height:100%;margin:0;font-family:var(--font-sans);background:none;color:var(--color-text);line-height:1.55;-webkit-font-smoothing:antialiased}body,#app,#admin-app{display:flex;flex-direction:column;min-height:100vh}#app,#admin-app{flex:1}a,a:visited{color:var(--color-link);text-decoration:none}a:hover,a:focus-visible{color:var(--color-link-hover);text-decoration:underline}a:focus-visible{outline:2px solid var(--color-accent);outline-offset:2px;border-radius:var(--radius-sm)}.app-shell{flex:1;display:flex;flex-direction:column;min-height:100vh}.app-main{flex:1;display:flex;flex-direction:column}.view-root{flex:1;width:100%;display:flex;flex-direction:column;gap:2rem;padding:var(--layout-padding);box-sizing:border-box;margin:0 auto;width:min(100%,var(--layout-max-width))}.view-root--wide{width:min(100%,1200px)}.view-root--narrow{max-width:540px}.view-header{display:flex;flex-direction:column;gap:.75rem}.view-header h1{margin:0;font-weight:600;color:var(--color-heading)}.view-lede{margin:0;color:var(--color-text-muted);font-size:1rem}.section-block{display:flex;flex-direction:column;gap:1rem}.section-block h2{margin:0;font-size:clamp(1.25rem,1.5vw + 1rem,1.65rem);font-weight:600;color:var(--color-heading)}.section-body{display:flex;flex-direction:column;gap:1rem}.button-row{display:flex;flex-wrap:wrap;gap:.75rem;justify-content:flex-start}.surface{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-lg);box-shadow:var(--shadow-soft)}.surface--tight{padding:var(--space-md)}button{font-family:inherit;font-size:1rem;font-weight:500;border-radius:var(--radius-sm);border:1px solid transparent;padding:.65rem 1.1rem;cursor:pointer;transition:all var(--transition-base);display:inline-flex;align-items:center;justify-content:center;gap:.4rem;background:var(--color-surface);color:var(--color-text)}button:focus-visible{outline:2px solid var(--color-accent);outline-offset:2px}button:disabled{cursor:not-allowed;filter:opacity(.6)}.btn-primary{background:var(--color-accent);color:var(--color-accent-contrast);border-color:var(--color-accent);box-shadow:var(--shadow-soft)}.btn-primary:hover:not(:disabled){background:var(--color-accent-strong);border-color:var(--color-accent-strong)}.btn-secondary{background:transparent;color:var(--color-text);border-color:var(--color-border)}.btn-secondary:hover:not(:disabled){border-color:var(--color-border-strong);background:var(--color-surface-subtle)}.btn-danger{background:var(--color-danger);color:var(--color-accent-contrast);border-color:transparent}.btn-danger:hover:not(:disabled){filter:brightness(.92)}input[type=text],input[type=search],input[type=email],textarea,select{font:inherit;width:100%;padding:.65rem .75rem;border-radius:var(--radius-sm);border:1px solid var(--color-border);background:var(--color-surface);color:var(--color-text);transition:border-color var(--transition-base),box-shadow var(--transition-base)}input:focus-visible,textarea:focus-visible,select:focus-visible{border-color:var(--color-accent);box-shadow:0 0 0 3px #c7d2fe;outline:none}label{display:flex;flex-direction:column;gap:.5rem;color:var(--color-text)}p{margiLine truncated
|
||||
@@ -1,2 +0,0 @@
|
||||
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))s(r);new MutationObserver(r=>{for(const i of r)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&s(o)}).observe(document,{childList:!0,subtree:!0});function n(r){const i={};return r.integrity&&(i.integrity=r.integrity),r.referrerPolicy&&(i.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?i.credentials="include":r.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function s(r){if(r.ep)return;r.ep=!0;const i=n(r);fetch(r.href,i)}})();function Zn(e){const t=Object.create(null);for(const n of e.split(","))t[n]=1;return n=>n in t}const K={},ft=[],Ae=()=>{},ir=()=>!1,un=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),es=e=>e.startsWith("onUpdate:"),re=Object.assign,ts=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},Si=Object.prototype.hasOwnProperty,U=(e,t)=>Si.call(e,t),T=Array.isArray,ut=e=>jt(e)==="[object Map]",bt=e=>jt(e)==="[object Set]",As=e=>jt(e)==="[object Date]",P=e=>typeof e=="function",J=e=>typeof e=="string",pe=e=>typeof e=="symbol",V=e=>e!==null&&typeof e=="object",or=e=>(V(e)||P(e))&&P(e.then)&&P(e.catch),lr=Object.prototype.toString,jt=e=>lr.call(e),xi=e=>jt(e).slice(8,-1),cr=e=>jt(e)==="[object Object]",dn=e=>J(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,At=Zn(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),hn=e=>{const t=Object.create(null);return(n=>t[n]||(t[n]=e(n)))},vi=/-\w/g,qe=hn(e=>e.replace(vi,t=>t.slice(1).toUpperCase())),Ai=/\B([A-Z])/g,Ye=hn(e=>e.replace(Ai,"-$1").toLowerCase()),ar=hn(e=>e.charAt(0).toUpperCase()+e.slice(1)),Rn=hn(e=>e?`on${ar(e)}`:""),Je=(e,t)=>!Object.is(e,t),zt=(e,...t)=>{for(let n=0;n<e.length;n++)e[n](...t)},fr=(e,t,n,s=!1)=>{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:s,value:n})},pn=e=>{const t=parseFloat(e);return isNaN(t)?e:t};let Rs;const gn=()=>Rs||(Rs=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function ns(e){if(T(e)){const t={};for(let n=0;n<e.length;n++){const s=e[n],r=J(s)?Ci(s):ns(s);if(r)for(const i in r)t[i]=r[i]}return t}else if(J(e)||V(e))return e}const Ri=/;(?![^(]*\))/g,Ti=/:([^]+)/,Oi=/\/\*[^]*?\*\//g;function Ci(e){const t={};return e.replace(Oi,"").split(Ri).forEach(n=>{if(n){const s=n.split(Ti);s.length>1&&(t[s[0].trim()]=s[1].trim())}}),t}function ss(e){let t="";if(J(e))t=e;else if(T(e))for(let n=0;n<e.length;n++){const s=ss(e[n]);s&&(t+=s+" ")}else if(V(e))for(const n in e)e[n]&&(t+=n+" ");return t.trim()}const Ii="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly",Pi=Zn(Ii);function ur(e){return!!e||e===""}function Mi(e,t){if(e.length!==t.length)return!1;let n=!0;for(let s=0;n&&s<e.length;s++)n=lt(e[s],t[s]);return n}function lt(e,t){if(e===t)return!0;let n=As(e),s=As(t);if(n||s)return n&&s?e.getTime()===t.getTime():!1;if(n=pe(e),s=pe(t),n||s)return e===t;if(n=T(e),s=T(t),n||s)return n&&s?Mi(e,t):!1;if(n=V(e),s=V(t),n||s){if(!n||!s)return!1;const r=Object.keys(e).length,i=Object.keys(t).length;if(r!==i)return!1;for(const o in e){const l=e.hasOwnProperty(o),c=t.hasOwnProperty(o);if(l&&!c||!l&&c||!lt(e[o],t[o]))return!1}}return String(e)===String(t)}function rs(e,t){return e.findIndex(n=>lt(n,t))}const dr=e=>!!(e&&e.__v_isRef===!0),Ni=e=>J(e)?e:e==null?"":T(e)||V(e)&&(e.toString===lr||!P(e.toString))?dr(e)?Ni(e.value):JSON.stringify(e,hr,2):String(e),hr=(e,t)=>dr(t)?hr(e,t.value):ut(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((n,[s,r],i)=>(n[Tn(s,i)+" =>"]=r,n),{})}:bt(t)?{[`Set(${t.size})`]:[...t.values()].map(n=>Tn(n))}:pe(t)?Tn(t):V(t)&&!T(t)&&!cr(t)?String(t):t,Tn=(e,t="")=>{var n;return pe(e)?`Symbol(${(n=e.description)!=null?n:t})`:e};let z;class pr{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this.parent=z,!t&&z&&(this.index=(z.scopes||(z.scopes=[])).push(this)-1)}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,n;if(this.scopes)for(t=0,n=this.scopes.length;t<n;t++)this.scopes[t].pause();for(t=0,n=this.effects.length;t<n;t++)this.effects[t].pause()}}resume(){if(this._active&&this._isPaused){this._isPaused=!1;let t,n;if(this.scopes)for(t=0,n=this.scopes.length;t<n;t++)this.scopes[t].resume();for(t=0,n=this.effects.length;t<n;t++)this.effects[t].resume()}}run(t){if(this._active){const n=z;try{return z=this,t()}finally{z=n}}}on(){++this._on===1&&(this.prevScope=z,z=this)}off(){this._on>0&&--this._on===0&&(z=this.prevScope,this.prevScope=void 0)}stop(t){if(this._active){this._active=!1;let n,s;for(n=0,s=this.effects.length;n<s;n++)this.effects[n].stop();for(this.effects.length=0,n=0,s=this.cleanups.length;n<s;n++)this.cleaLine truncated
|
||||
`,t)}function ic(){if(!_s())return Kn.stubThis(new Promise(t=>t(!1)));const e=globalThis.PublicKeyCredential;return e?.isConditionalMediationAvailable===void 0?Kn.stubThis(new Promise(t=>t(!1))):Kn.stubThis(e.isConditionalMediationAvailable())}const Kn={stubThis:e=>e};function oc({error:e,options:t}){const{publicKey:n}=t;if(!n)throw Error("options was missing required publicKey property");if(e.name==="AbortError"){if(t.signal instanceof AbortSignal)return new Y({message:"Authentication ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else{if(e.name==="NotAllowedError")return new Y({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if(e.name==="SecurityError"){const s=globalThis.location.hostname;if(pi(s)){if(n.rpId!==s)return new Y({message:`The RP ID "${n.rpId}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else return new Y({message:`${globalThis.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e})}else if(e.name==="UnknownError")return new Y({message:"The authenticator was unable to process the specified options, or could not create a new assertion signature",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}async function lc(e){!e.optionsJSON&&e.challenge&&(console.warn("startAuthentication() was not called correctly. It will try to continue with the provided options, but this call should be refactored to use the expected call structure instead. See https://simplewebauthn.dev/docs/packages/browser#typeerror-cannot-read-properties-of-undefined-reading-challenge for more information."),e={optionsJSON:e});const{optionsJSON:t,useBrowserAutofill:n=!1,verifyBrowserAutofillInput:s=!0}=e;if(!_s())throw new Error("WebAuthn is not supported in this browser");let r;t.allowCredentials?.length!==0&&(r=t.allowCredentials?.map(hi));const i={...t,challenge:fn(t.challenge),allowCredentials:r},o={};if(n){if(!await ic())throw Error("Browser does not support WebAuthn autofill");if(document.querySelectorAll("input[autocomplete$='webauthn']").length<1&&s)throw Error('No <input> with "webauthn" as the only or last value in its `autocomplete` attribute was detected');o.mediation="conditional",i.allowCredentials=[]}o.publicKey=i,o.signal=gi.createNewAbortSignal();let l;try{l=await navigator.credentials.get(o)}catch(v){throw oc({error:v,options:o})}if(!l)throw new Error("Authentication was not completed");const{id:c,rawId:d,response:u,type:p}=l;let S;return u.userHandle&&(S=ve(u.userHandle)),{id:c,rawId:ve(d),response:{authenticatorData:ve(u.authenticatorData),clientDataJSON:ve(u.clientDataJSON),signature:ve(u.signature),userHandle:S},type:p,clientExtensionResults:l.getClientExtensionResults(),authenticatorAttachment:mi(l.authenticatorAttachment)}}class cc extends WebSocket{#n=[];#e=[];#t=null;#s=!1;constructor(t,n,s,r,i){super(new URL(s,document.baseURI.replace(/^http/,"ws")),r),this.binaryType=i||"blob",this.onopen=()=>{this.#s=!0,t(this)},this.onmessage=o=>{this.#e.length?this.#e.shift().resolve(o.data):this.#n.push(o.data)},this.onclose=o=>{if(!this.#s){n(new Error(`WebSocket ${this.url} failed to connect, code ${o.code}`));return}this.#t=o.wasClean?new Error(`Websocket ${this.url} closed ${o.code}`):new Error(`WebSocket ${this.url} closed with error ${o.code}`),this.#e.splice(0).forEach(l=>l.reject(this.#t))}}receive(){return this.#n.length?Promise.resolve(this.#n.shift()):this.#t?Promise.reject(this.#t):new Promise((t,n)=>this.#e.push({resolve:t,reject:n}))}async receive_bytes(){const t=await this.receive();if(typeof t=="string")throw console.error("WebSocket received text data, expected a binary message",t),new Error("WebSocket received text data, expected a binary message");return t instanceof Blob?t.bytes():new Uint8Array(t)}async receive_json(){const t=await this.receive();if(typeof t!="string")throw console.error("WebSocket received binary data, expected JSON string",t),new Error("WebSocket received binary data, expected JSON string");try{return JSON.parse(t)}catch(n){throw console.error("Failed to parse JSON from WebSocket message",t,n),new Error("Failed to parse JSON from WebSocket message")}}send_json(t){let n;try{n=JSON.stringify(t)}catch(s){throw new Error(`Failed to stringify data for WebSocket: ${s.message}`)}this.send(n)}}function bi(e,t={}){const{protocols:n,binaryType:s}=t;return new Promise((r,i)=>{new cc(r,i,e,n,s)})}let Yt=null,nt=null;async function ac(){return nt||Yt||(Yt=fetch("/auth/api/settings").then(e=>e.ok?e.json():{}).then(e=>(nt=e||{},nt)).catch(()=>(nt={},nt)),Yt)}function Pt(){const e=nt?.ui_base_path||"/auth/";return e==="/"?"/":e.endsWith("/")?e:e+"/"}function Ic(){return Pt()==="/"?"/admin/":Pt()+"admin/"}function Pc(e=""){const t=e.startsWith("/")?e.slice(1):e;return t?Pt()==="/"?"/"+t:Pt()+t:Pt()}const fc=1e3;class xn extends Error{constructor(t,n,s){super(s?.detail||`Request failed: ${n.status}`),this.name="ApiError",this.url=t,this.status=n.status,this.statusText=n.statusText,this.dat
Line truncated
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as j,c as L,d as r,e as o,f as i,i as O,t as f,F as p,x as I,y as ee,k as ae,r as v,h as M,g as B,q as h,w as se,j as Ee,l as N,z,A as q,u as Q,B as X,o as Ne,C as Te,a as Ae,m as Me,b as Ie,v as Le}from"./_plugin-vue_export-helper-R4vr2A9I.js";import{u as ie,U as _e,_ as Ve,a as ze,R as qe,N as G,M as Be,b as je,L as Fe,A as xe,B as Ge,c as He}from"./AccessDenied-guOGfNm-.js";import"./helpers-CU0-cyzg.js";const Je={class:"permissions-section"},We={class:"actions"},Ye={class:"org-table"},Ze={key:0},Ke=["onClick"],Qe=["onClick"],Xe={class:"role-names"},ea={class:"center"},aa={key:0,class:"center"},sa=["onClick"],ia={key:0,class:"permissions-section"},ta={class:"matrix-wrapper"},na={class:"matrix-scroll"},oa=["title"],la=["title"],ra={class:"display-text"},da=["checked","onChange"],ua={class:"actions"},ma={class:"org-table"},ca={class:"perm-name-cell"},ga={class:"perm-title"},ya={class:"display-text"},va=["onClick"],pa={class:"perm-id-info"},fa={class:"id-text"},ha={class:"perm-members center"},$a={class:"perm-actions center"},ba=["onClick"],wa={__name:"AdminOverview",props:{info:Object,orgs:Array,permissions:Array,permissionSummary:Object},emits:["createOrg","openOrg","updateOrg","deleteOrg","toggleOrgPermission","openDialog","deletePermission","renamePermissionDisplay"],setup(a,{emit:C}){const D=a,U=L(()=>[...D.orgs].sort((m,s)=>{const t=m.display_name.localeCompare(s.display_name);return t!==0?t:m.uuid.localeCompare(s.uuid)})),k=L(()=>[...D.permissions].sort((m,s)=>m.id.localeCompare(s.id)));function R(m){return m.roles.slice().sort((s,t)=>s.display_name.localeCompare(t.display_name)).map(s=>s.display_name).join(", ")}return(m,s)=>(o(),r(p,null,[i("div",Je,[i("h2",null,f(a.info.is_global_admin?"Organizations":"Your Organizations"),1),i("div",We,[a.info.is_global_admin?(o(),r("button",{key:0,onClick:s[0]||(s[0]=t=>m.$emit("createOrg"))},"+ Create Org")):O("",!0)]),i("table",Ye,[i("thead",null,[i("tr",null,[s[2]||(s[2]=i("th",null,"Name",-1)),s[3]||(s[3]=i("th",null,"Roles",-1)),s[4]||(s[4]=i("th",null,"Members",-1)),a.info.is_global_admin?(o(),r("th",Ze,"Actions")):O("",!0)])]),i("tbody",null,[(o(!0),r(p,null,I(U.value,t=>(o(),r("tr",{key:t.uuid},[i("td",null,[i("a",{href:"#org/{{o.uuid}}",onClick:ae(u=>m.$emit("openOrg",t),["prevent"])},f(t.display_name),9,Ke),a.info.is_global_admin||a.info.is_org_admin?(o(),r("button",{key:0,onClick:u=>m.$emit("updateOrg",t),class:"icon-btn edit-org-btn","aria-label":"Rename organization",title:"Rename organization"},"✏️",8,Qe)):O("",!0)]),i("td",Xe,f(R(t)),1),i("td",ea,f(t.roles.reduce((u,c)=>u+c.users.length,0)),1),a.info.is_global_admin?(o(),r("td",aa,[i("button",{onClick:u=>m.$emit("deleteOrg",t),class:"icon-btn delete-icon","aria-label":"Delete organization",title:"Delete organization"},"❌",8,sa)])):O("",!0)]))),128))])])]),a.info.is_global_admin?(o(),r("div",ia,[s[8]||(s[8]=i("h2",null,"Permissions",-1)),i("div",ta,[i("div",na,[i("div",{class:"perm-matrix-grid",style:ee({gridTemplateColumns:"minmax(180px, 1fr) "+U.value.map(()=>"2.2rem").join(" ")})},[s[5]||(s[5]=i("div",{class:"grid-head perm-head"},"Permission",-1)),(o(!0),r(p,null,I(U.value,t=>(o(),r("div",{key:"head-"+t.uuid,class:"grid-head org-head",title:t.display_name},[i("span",null,f(t.display_name),1)],8,oa))),128)),(o(!0),r(p,null,I(k.value,t=>(o(),r(p,{key:t.id},[i("div",{class:"perm-name",title:t.id},[i("span",ra,f(t.display_name),1)],8,la),(o(!0),r(p,null,I(U.value,u=>(o(),r("div",{key:u.uuid+"-"+t.id,class:"matrix-cell"},[i("input",{type:"checkbox",checked:u.permissions.includes(t.id),onChange:c=>m.$emit("toggleOrgPermission",u,t.id,c.target.checked)},null,40,da)]))),128))],64))),128))],4)]),s[6]||(s[6]=i("p",{class:"matrix-hint muted"},"Toggle which permissions each organization can grant to its members.",-1))]),i("div",ua,[a.info.is_global_admin?(o(),r("button",{key:0,onClick:s[1]||(s[1]=t=>m.$emit("openDialog","perm-create",{display_name:"",id:""}))},"+ Create Permission")):O("",!0)]),i("table",ma,[s[7]||(s[7]=i("thead",null,[i("tr",null,[i("th",{scope:"col"},"Permission"),i("th",{scope:"col",class:"center"},"Members"),i("th",{scope:"col",class:"center"},"Actions")])],-1)),i("tbody",null,[(o(!0),r(p,null,I(k.value,t=>(o(),r("tr",{key:t.id},[i("td",ca,[i("div",ga,[i("span",ya,f(t.display_name),1),i("button",{onClick:u=>m.$emit("renamePermissionDisplay",t),class:"icon-btn edit-display-btn","aria-label":"Edit display name",title:"Edit display name"},"✏️",8,va)]),i("div",pa,[i("span",fa,f(t.id),1)])]),i("td",ha,f(a.permissionSummary[t.id]?.userCount||0),1),i("td",$a,[i("button",{onClick:u=>m.$emit("deletePermission",t),class:"icon-btn delete-icon","aria-label":"Delete permission",title:"Delete permission"},"❌",8,ba)])]))),128))])])])):O("",!0)],64))}},ka=j(wa,[["__scopeId","data-v-3b9e6d03"]]),Da=["title"],Oa={class:"org-name"},Ua={class:"matrix-wrapper"},Ca={class:"matrix-scroll"},Ra=["title"],Sa=["title"],Pa=["checked","onChange"],Ea={class:"roles-grid"},Na=["onDrop"],Ta={classLine truncated
|
||||
@@ -1 +0,0 @@
|
||||
.permissions-section[data-v-3b9e6d03]{margin-bottom:var(--space-xl)}.permissions-section h2[data-v-3b9e6d03]{margin-bottom:var(--space-md)}.actions[data-v-3b9e6d03]{display:flex;flex-wrap:wrap;gap:var(--space-sm);align-items:center}.actions button[data-v-3b9e6d03]{width:auto}.org-table a[data-v-3b9e6d03]{text-decoration:none;color:var(--color-link)}.org-table a[data-v-3b9e6d03]:hover{text-decoration:underline}.org-table .center[data-v-3b9e6d03]{width:6rem;min-width:6rem}.org-table .role-names[data-v-3b9e6d03]{max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.perm-name-cell[data-v-3b9e6d03]{display:flex;flex-direction:column;gap:.3rem}.perm-title[data-v-3b9e6d03]{font-weight:600;color:var(--color-heading)}.perm-id-info[data-v-3b9e6d03]{font-size:.8rem;color:var(--color-text-muted)}.icon-btn[data-v-3b9e6d03]{background:none;border:none;color:var(--color-text-muted);padding:.2rem;border-radius:var(--radius-sm);cursor:pointer;transition:background .2s ease,color .2s ease}.icon-btn[data-v-3b9e6d03]:hover{color:var(--color-heading);background:var(--color-surface-muted)}.delete-icon[data-v-3b9e6d03]{color:var(--color-danger)}.delete-icon[data-v-3b9e6d03]:hover{background:var(--color-danger-bg);color:var(--color-danger-text)}.matrix-wrapper[data-v-3b9e6d03]{margin:var(--space-md) 0;padding:var(--space-lg)}.matrix-scroll[data-v-3b9e6d03]{overflow-x:auto}.matrix-hint[data-v-3b9e6d03]{font-size:.8rem;color:var(--color-text-muted)}.perm-matrix-grid[data-v-3b9e6d03]{display:inline-grid;gap:.25rem;align-items:stretch}.perm-matrix-grid[data-v-3b9e6d03]>*{padding:.35rem .45rem;font-size:.75rem}.perm-matrix-grid .grid-head[data-v-3b9e6d03]{color:var(--color-text-muted);text-transform:uppercase;font-weight:600;letter-spacing:.05em}.perm-matrix-grid .perm-head[data-v-3b9e6d03]{display:flex;align-items:flex-end;justify-content:flex-start;padding:.35rem .45rem;font-size:.75rem}.perm-matrix-grid .org-head[data-v-3b9e6d03]{display:flex;align-items:flex-end;justify-content:center}.perm-matrix-grid .org-head span[data-v-3b9e6d03]{writing-mode:vertical-rl;transform:rotate(180deg);font-size:.65rem}.perm-name[data-v-3b9e6d03]{font-weight:600;color:var(--color-heading);padding:.35rem .45rem;font-size:.75rem}.display-text[data-v-3b9e6d03]{margin-right:var(--space-xs)}.edit-display-btn[data-v-3b9e6d03]{padding:.1rem .2rem;font-size:.8rem}.edit-org-btn[data-v-3b9e6d03]{padding:.1rem .2rem;font-size:.8rem;margin-left:var(--space-xs)}.perm-actions[data-v-3b9e6d03],.center[data-v-3b9e6d03]{text-align:center}.muted[data-v-3b9e6d03]{color:var(--color-text-muted)}.card.surface[data-v-b8b4bfbc]{padding:var(--space-lg)}.org-title[data-v-b8b4bfbc]{display:flex;align-items:center;gap:var(--space-sm);margin-bottom:var(--space-lg)}.org-name[data-v-b8b4bfbc]{font-size:1.5rem;font-weight:600;color:var(--color-heading)}.icon-btn[data-v-b8b4bfbc]{background:none;border:none;color:var(--color-text-muted);padding:.2rem;border-radius:var(--radius-sm);cursor:pointer;transition:background .2s ease,color .2s ease}.icon-btn[data-v-b8b4bfbc]:hover{color:var(--color-heading);background:var(--color-surface-muted)}.matrix-wrapper[data-v-b8b4bfbc]{margin:var(--space-md) 0;padding:var(--space-lg)}.matrix-scroll[data-v-b8b4bfbc]{overflow-x:auto}.matrix-hint[data-v-b8b4bfbc]{font-size:.8rem;color:var(--color-text-muted)}.perm-matrix-grid[data-v-b8b4bfbc]{display:inline-grid;gap:.25rem;align-items:stretch}.perm-matrix-grid[data-v-b8b4bfbc]>*{padding:.35rem .45rem;font-size:.75rem}.perm-matrix-grid .grid-head[data-v-b8b4bfbc]{color:var(--color-text-muted);text-transform:uppercase;font-weight:600;letter-spacing:.05em}.perm-matrix-grid .perm-head[data-v-b8b4bfbc]{display:flex;align-items:flex-end;justify-content:flex-start;padding:.35rem .45rem;font-size:.75rem}.perm-matrix-grid .role-head[data-v-b8b4bfbc]{display:flex;align-items:flex-end;justify-content:center}.perm-matrix-grid .role-head span[data-v-b8b4bfbc]{writing-mode:vertical-rl;transform:rotate(180deg);font-size:.65rem}.perm-matrix-grid .add-role-head[data-v-b8b4bfbc]{cursor:pointer}.perm-name[data-v-b8b4bfbc]{font-weight:600;color:var(--color-heading);padding:.35rem .45rem;font-size:.75rem}.roles-grid[data-v-b8b4bfbc]{display:flex;gap:var(--space-lg);margin-top:var(--space-lg)}.role-column[data-v-b8b4bfbc]{flex:1;min-width:200px;border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-md)}.role-header[data-v-b8b4bfbc]{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-md)}.role-name[data-v-b8b4bfbc]{display:flex;align-items:center;gap:var(--space-xs);font-size:1.1rem;color:var(--color-heading)}.role-actions[data-v-b8b4bfbc]{display:flex;gap:var(--space-xs)}.plus-btn[data-v-b8b4bfbc]{background:var(--color-accent-soft);color:var(--color-accent);border:none;border-radius:var(--radius-sm);padding:.25rem .45rem;font-size:1.1rem;cursor:pointer}.plus-btn[data-v-b8b4bfbc]:hover{background:#2563eb2e}.user-list[data-v-b8b4bfbc]{lLine truncated
|
||||
@@ -1 +0,0 @@
|
||||
.view-lede[data-v-0cc830bd]{margin:0;color:var(--color-text-muted);font-size:1rem}.section-header[data-v-0cc830bd]{display:flex;flex-direction:column;gap:.4rem}.section-description[data-v-0cc830bd]{margin:0;color:var(--color-text-muted)}.empty-state[data-v-0cc830bd]{margin:0;color:var(--color-text-muted);text-align:center;padding:1rem 0}.logout-button[data-v-0cc830bd]{align-self:flex-start}.logout-row[data-v-0cc830bd]{gap:1rem}.logout-row.single[data-v-0cc830bd]{justify-content:flex-start}.logout-note[data-v-0cc830bd]{margin:.75rem 0 0;color:var(--color-text-muted);font-size:.875rem}@media(max-width:720px){.logout-button[data-v-0cc830bd]{width:100%}}.host-view[data-v-88828278]{padding:3rem 1.5rem 4rem}.host-actions[data-v-88828278]{display:flex;flex-direction:column;gap:.75rem}.host-actions .button-row[data-v-88828278]{gap:.75rem;flex-wrap:wrap}.host-actions .button-row button[data-v-88828278]{flex:0 0 auto}.note[data-v-88828278],.empty-state[data-v-88828278]{margin:0;color:var(--color-text-muted)}@media(max-width:600px){.host-actions .button-row[data-v-88828278]{flex-direction:column}.host-actions .button-row button[data-v-88828278]{width:100%}}
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as F,r as m,w as Y,o as T,a as z,c as v,m as O,b as j,d as k,e as l,f as t,g as N,h as $,i as C,u as o,j as J,k as K,n as G,F as Q,l as H,t as b,p as X,q as E,s as Z,v as ee}from"./_plugin-vue_export-helper-R4vr2A9I.js";import{u as x,B as se,U as R,_ as te,a as oe,N as ae,M as ne,R as ie,L as le,b as re,A as ue,c as de}from"./AccessDenied-guOGfNm-.js";import{g as U}from"./helpers-CU0-cyzg.js";const ce={class:"view-root","data-view":"profile"},ve={class:"view-header"},fe={class:"section-block"},ge={class:"section-block"},me={class:"section-body"},pe={class:"button-row"},he={class:"section-block"},ye=["disabled"],we=["disabled"],_e=["disabled"],be={key:0,class:"logout-note"},ke={key:1,class:"logout-note"},Ie={__name:"ProfileView",setup(V){const e=x(),f=m(null),r=m(!1),p=m(!1),y=m(""),w=m(!1),A=m(null),_=m(null);Y(r,d=>{d&&(y.value=e.userInfo?.user?.user_name||"")}),T(()=>{f.value=setInterval(()=>{e.userInfo&&(e.userInfo={...e.userInfo})},6e4)}),z(()=>{f.value&&clearInterval(f.value)});const c=async()=>{try{await X.register(null,null,()=>{e.showMessage("Adding new passkey...","info")}),await e.loadUserInfo(),e.showMessage("New passkey added successfully!","success",3e3)}catch(d){console.error("Failed to add new passkey:",d),e.showMessage(d.message,"error")}},S=async d=>{const s=d?.credential_uuid;if(s&&confirm("Are you sure you want to delete this passkey?"))try{await e.deleteCredential(s),e.showMessage("Passkey deleted successfully!","success",3e3)}catch(n){e.showMessage(`Failed to delete passkey: ${n.message}`,"error")}},L=v(()=>e.settings?.rp_name||"this service"),u=v(()=>e.userInfo?.sessions||[]),i=v(()=>u.value.find(s=>s.is_current)?.host||"this host"),h=m({}),B=async d=>{const s=d?.id;if(s){h.value={...h.value,[s]:!0};try{await e.terminateSession(s)}catch(n){e.showMessage(n.message||"Failed to terminate session","error",5e3)}finally{const n={...h.value};delete n[s],h.value=n}}},P=async()=>{await e.logoutEverywhere()},M=async()=>{await e.logout()},a=()=>{y.value=e.userInfo?.user?.user_name||"",r.value=!0},g=v(()=>!!(e.userInfo?.is_global_admin||e.userInfo?.is_org_admin)),I=v(()=>u.value.length>1),D=v(()=>{const d=[{label:"Auth",href:O()}];return g.value&&d.push({label:"Admin",href:j()}),d}),W=async()=>{const d=y.value.trim();if(!d){e.showMessage("Name cannot be empty","error");return}try{w.value=!0,await E("/auth/api/user/display-name",{method:"PUT",body:{display_name:d}}),r.value=!1,await e.loadUserInfo(),e.showMessage("Name updated successfully!","success",3e3)}catch(s){e.showMessage(s.message||"Failed to update name","error")}finally{w.value=!1}};return(d,s)=>(l(),k("section",ce,[t("header",ve,[s[10]||(s[10]=t("h1",null,"👋 Welcome!",-1)),N(se,{entries:D.value},null,8,["entries"]),s[11]||(s[11]=t("p",{class:"view-lede"},"Manage your account details and passkeys.",-1))]),t("section",fe,[o(e).userInfo?.user?(l(),$(R,{key:0,name:o(e).userInfo.user.user_name,visits:o(e).userInfo.user.visits||0,"created-at":o(e).userInfo.user.created_at,"last-seen":o(e).userInfo.user.last_seen,loading:o(e).isLoading,"update-endpoint":"/auth/api/user/display-name",onSaved:s[0]||(s[0]=n=>o(e).loadUserInfo()),onEditName:a},null,8,["name","visits","created-at","last-seen","loading"])):C("",!0)]),t("section",ge,[s[12]||(s[12]=t("div",{class:"section-header"},[t("h2",null,"Your Passkeys"),t("p",{class:"section-description"},"Keep at least one trusted passkey so you can always sign in.")],-1)),t("div",me,[N(te,{credentials:o(e).userInfo?.credentials||[],"aaguid-info":o(e).userInfo?.aaguid_info||{},loading:o(e).isLoading,"hovered-credential-uuid":A.value,"hovered-session-credential-uuid":_.value?.credential_uuid,"allow-delete":"",onDelete:S,onCredentialHover:s[1]||(s[1]=n=>A.value=n)},null,8,["credentials","aaguid-info","loading","hovered-credential-uuid","hovered-session-credential-uuid"]),t("div",pe,[t("button",{onClick:c,class:"btn-primary"},"Add New Passkey"),t("button",{onClick:s[2]||(s[2]=n=>p.value=!0),class:"btn-secondary"},"Add Another Device")])])]),N(oe,{sessions:u.value,"terminating-sessions":h.value,"hovered-credential-uuid":A.value,onTerminate:B,onSessionHover:s[3]||(s[3]=n=>_.value=n),"section-description":"Review where you're signed in and end any sessions you no longer recognize."},null,8,["sessions","terminating-sessions","hovered-credential-uuid"]),r.value?(l(),$(ne,{key:0,onClose:s[6]||(s[6]=n=>r.value=!1)},{default:J(()=>[s[13]||(s[13]=t("h3",null,"Edit Display Name",-1)),t("form",{onSubmit:K(W,["prevent"]),class:"modal-form"},[N(ae,{label:"Display Name",modelValue:y.value,"onUpdate:modelValue":s[4]||(s[4]=n=>y.value=n),busy:w.value,onCancel:s[5]||(s[5]=n=>r.value=!1)},null,8,["modelValue","busy"])],32)]),_:1})):C("",!0),t("section",he,[t("div",{class:G(["button-row logout-row",{single:!I.value}])},[t("button",{type:"button",class:"btn-secondary",onClick:s[7]||(s[7]=(...n)=>o(U)&&o(U)(...n))}," Back "),I.value?(l(),k(Q,{key:1},[t("button",{onClick:M,class:"btn-danger logout-button",disabled:o(e).isLLine truncated
|
||||
@@ -1 +0,0 @@
|
||||
import{c as o,W as d,o as i,h as s,e as m,u as l,v as h}from"./_plugin-vue_export-helper-R4vr2A9I.js";import{R as p}from"./RestrictedAuth-BIGLs28V.js";import{g as n}from"./helpers-CU0-cyzg.js";const f={__name:"RestrictedForward",setup(w){const a=o(()=>d()),r=o(()=>{const t=document.documentElement.getAttribute("data-mode");return t==="reauth"?"reauth":t==="forbidden"?"forbidden":"login"});function c(){location.reload()}function u(){const e=a.value||"/auth/";window.location.pathname!==e&&history.replaceState(null,"",e),window.location.href=e}return i(()=>{window.addEventListener("keydown",e=>{e.key==="Escape"&&n()})}),(e,t)=>(m(),s(p,{mode:r.value,onAuthenticated:c,onBack:l(n),onHome:u},null,8,["mode","onBack"]))}};h(f).mount("#app");
|
||||
@@ -1 +0,0 @@
|
||||
function f(r){if(!r)return"Never";const s=new Date(r),u=s-new Date,e=u>0,a=Math.abs(u),n=Math.round(a/(1e3*60)),o=Math.round(a/(1e3*60*60)),t=Math.round(a/(1e3*60*60*24));return a<1e3*60?"Now":n<=60?e?`In ${n} minute${n===1?"":"s"}`:n===1?"a minute ago":`${n} minutes ago`:o<=24?e?`In ${o} hour${o===1?"":"s"}`:o===1?"an hour ago":`${o} hours ago`:t<=14?e?`In ${t} day${t===1?"":"s"}`:t===1?"a day ago":`${t} days ago`:s.toLocaleDateString(void 0,{year:"numeric",month:"long",day:"numeric"})}const c=()=>history.back()||window.close();export{f,c as g};
|
||||
@@ -1 +0,0 @@
|
||||
.center[data-v-4f202f9a]{text-align:center}.button-row.center[data-v-4f202f9a]{display:flex;justify-content:center}.section-body[data-v-4f202f9a]{gap:1.25rem}.name-edit span[data-v-4f202f9a]{color:var(--color-text-muted);font-size:.9rem}
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as M,G as F,r as i,c as v,W as b,o as U,d as c,e as u,i as V,f as t,t as g,n as $,z,A as E,S as I,C as N,q as R,X as D,V as K,p as O,v as j}from"./_plugin-vue_export-helper-R4vr2A9I.js";const q={class:"app-shell"},G={key:0,class:"global-status",style:{display:"block"}},H={class:"view-root"},J={class:"surface surface--tight",style:{"max-width":"560px",margin:"0 auto",width:"100%"}},L={class:"view-header",style:{"text-align":"center"}},W={class:"view-lede"},X={key:0,class:"section-block"},Y={key:1,class:"section-block"},Q={class:"section-body center"},Z={key:2,class:"section-block"},ee={class:"section-body"},se={class:"name-edit"},te=["disabled"],ae=["disabled"],ne={__name:"ResetApp",setup(ie){const o=F({show:!1,message:"",type:"info"}),d=i(!0),n=i(!1),r=i(""),x=i(null),p=i(null),f=i(""),m=i("");let h=null;const P=v(()=>p.value?.session_type||"your enrollment"),S=v(()=>d.value?"Preparing your secure enrollment…":y.value?`Finish up ${P.value}. You may edit the name below if needed, and it will be saved to your passkey.`:"This reset link is no longer valid.");v(()=>b());const y=v(()=>!!(r.value&&p.value));function l(e,s="info",a=3e3){o.show=!0,o.message=e,o.type=s,h&&clearTimeout(h),a>0&&(h=setTimeout(()=>{o.show=!1},a))}async function T(){try{const e=await N();x.value=e,e?.rp_name&&(document.title=`${e.rp_name} · Passkey Setup`)}catch(e){console.warn("Unable to load settings",e)}}async function C(){if(r.value)try{p.value=await R(`/auth/api/user-info?reset=${encodeURIComponent(r.value)}`,{method:"POST"}),f.value=p.value?.user?.user_name||""}catch(e){console.error("Failed to load user info",e);const s=e instanceof D?e.data?.detail||"Reset link is invalid or expired.":K(e);m.value=s,l(s,"error",0)}}async function _(){if(!y.value||n.value)return;n.value=!0,l("Starting passkey registration…","info");let e;try{const s=f.value.trim()||null;e=await O.register(r.value,s)}catch(s){n.value=!1;const a=s?.message||"Passkey registration cancelled",k=a==="Passkey registration cancelled";l(k?a:`Registration failed: ${a}`,k?"info":"error",4e3);return}try{await A(e)}catch(s){n.value=!1;const a=s?.message||"Failed to establish session";l(a,"error",4e3);return}l("Passkey registered successfully!","success",800),setTimeout(()=>{n.value=!1,w()},800)}async function A(e){if(!e?.session_token)throw new Error("Registration response missing session_token");return await R("/auth/api/set-session",{method:"POST",headers:{Authorization:`Bearer ${e.session_token}`}})}function w(){const e=b.value||"/auth/";window.location.pathname!==e&&history.replaceState(null,"",e),window.location.reload()}function B(){const e=window.location.pathname.split("/").filter(Boolean);if(!e.length)return"";const s=e[e.length-1],a=e.slice(0,-1);return a.length>1||a.length===1&&a[0]!=="auth"||!s.includes(".")?"":s}return U(async()=>{if(r.value=B(),await T(),!r.value){const e="Reset link is missing or malformed.";m.value=e,l(e,"error",0),d.value=!1;return}await C(),d.value=!1}),(e,s)=>(u(),c("div",q,[o.show?(u(),c("div",G,[t("div",{class:$(["status",o.type])},g(o.message),3)])):V("",!0),t("main",H,[t("div",J,[t("header",L,[s[1]||(s[1]=t("h1",null,"🔑 Registration",-1)),t("p",W,g(S.value),1)]),d.value?(u(),c("section",X,[...s[2]||(s[2]=[t("div",{class:"section-body center"},[t("p",null,"Loading reset details…")],-1)])])):y.value?(u(),c("section",Z,[t("div",ee,[t("label",se,[s[3]||(s[3]=t("span",null,"👤 Name",-1)),z(t("input",{type:"text","onUpdate:modelValue":s[0]||(s[0]=a=>f.value=a),disabled:n.value,maxlength:"64",onKeyup:I(_,["enter"])},null,40,te),[[E,f.value]])]),t("button",{class:"btn-primary",disabled:n.value,onClick:_},g(n.value?"Registering…":"Register Passkey"),9,ae)])])):(u(),c("section",Y,[t("div",Q,[t("p",null,g(m.value),1),t("div",{class:"button-row center",style:{"justify-content":"center"}},[t("button",{class:"btn-secondary",onClick:w},"Return to sign-in")])])]))])])]))}},oe=M(ne,[["__scopeId","data-v-4f202f9a"]]);j(oe).mount("#app");
|
||||
@@ -1 +0,0 @@
|
||||
import{c as r,o as c,h as i,e as d,v as u}from"./_plugin-vue_export-helper-R4vr2A9I.js";import{R as p}from"./RestrictedAuth-BIGLs28V.js";const h={__name:"RestrictedApi",setup(m){const a=r(()=>{const n=new URLSearchParams(window.location.hash.slice(1)).get("mode");return n==="reauth"?"reauth":n==="forbidden"?"forbidden":"login"});function t(e){window.parent&&window.parent!==window&&window.parent.postMessage(e,"*")}function s(e){t({type:"auth-success",authenticated:!0,sessionToken:e.session_token})}function o(){t({type:"auth-back"})}return c(()=>{t({type:"auth-ready"}),window.addEventListener("keydown",e=>{e.key==="Escape"&&o()})}),(e,n)=>(d(),i(p,{mode:a.value,onAuthenticated:s,onBack:o},null,8,["mode"]))}};u(h).mount("#app");
|
||||
@@ -1,18 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Auth Profile</title>
|
||||
<script type="module" crossorigin src="/auth/assets/auth-a0yJ_sei.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-R4vr2A9I.js">
|
||||
<link rel="modulepreload" crossorigin href="/auth/assets/helpers-CU0-cyzg.js">
|
||||
<link rel="modulepreload" crossorigin href="/auth/assets/AccessDenied-guOGfNm-.js">
|
||||
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-Bx2cFCEC.css">
|
||||
<link rel="stylesheet" crossorigin href="/auth/assets/AccessDenied-TAST_piX.css">
|
||||
<link rel="stylesheet" crossorigin href="/auth/assets/auth-CBojJKUK.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,9 +0,0 @@
|
||||
<html style="background: transparent">
|
||||
<script type="module" crossorigin src="/auth/assets/restricted-DVCvYFGN.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-R4vr2A9I.js">
|
||||
<link rel="modulepreload" crossorigin href="/auth/assets/RestrictedAuth-BIGLs28V.js">
|
||||
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-Bx2cFCEC.css">
|
||||
<link rel="stylesheet" crossorigin href="/auth/assets/RestrictedAuth-CMHKrNJh.css">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<div id="app"></div>
|
||||
@@ -1,17 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Access Restricted</title>
|
||||
<script type="module" crossorigin src="/auth/assets/forward-BHNzlQhM.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-R4vr2A9I.js">
|
||||
<link rel="modulepreload" crossorigin href="/auth/assets/RestrictedAuth-BIGLs28V.js">
|
||||
<link rel="modulepreload" crossorigin href="/auth/assets/helpers-CU0-cyzg.js">
|
||||
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-Bx2cFCEC.css">
|
||||
<link rel="stylesheet" crossorigin href="/auth/assets/RestrictedAuth-CMHKrNJh.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,15 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Complete Passkey Setup</title>
|
||||
<script type="module" crossorigin src="/auth/assets/reset-YnZxhnI5.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-R4vr2A9I.js">
|
||||
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-Bx2cFCEC.css">
|
||||
<link rel="stylesheet" crossorigin href="/auth/assets/reset-DXzuKgh6.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,359 @@
|
||||
"""
|
||||
Cross-device (remote) authentication support.
|
||||
|
||||
This module manages the flow for authenticating from another device:
|
||||
1. Device A (requesting) creates a remote auth request and displays QR/link
|
||||
2. Device B (authenticating) opens the link and authenticates with passkey
|
||||
3. Device A receives the session via WebSocket notification
|
||||
|
||||
Alternative flow (initiated from profile/authenticating device):
|
||||
1. Device A (requesting) creates request and displays short pairing code
|
||||
2. Device B (authenticating) enters the pairing code in their profile
|
||||
3. Device B authenticates, Device A receives the session
|
||||
|
||||
The requests are stored in-memory with short expiration (5 minutes).
|
||||
The link uses the same /{token} endpoint as reset tokens, but the server
|
||||
distinguishes between them by checking if the token exists in remoteauth first.
|
||||
The first 3 words of the token serve as the pairing code for manual entry.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Callable
|
||||
from uuid import UUID
|
||||
|
||||
from paskia.util import passphrase
|
||||
|
||||
# Remote auth requests expire after this duration
|
||||
REMOTE_AUTH_LIFETIME = timedelta(minutes=5)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemoteAuthRequest:
|
||||
"""A pending remote authentication request."""
|
||||
|
||||
key: str # The 3-word passphrase code
|
||||
created_at: datetime
|
||||
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, 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
|
||||
|
||||
|
||||
class RemoteAuthManager:
|
||||
"""Manages pending remote authentication requests."""
|
||||
|
||||
def __init__(self):
|
||||
self._requests: dict[str, RemoteAuthRequest] = {} # keyed by 3-word code
|
||||
self._cleanup_task: asyncio.Task | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def start(self):
|
||||
"""Start the cleanup background task."""
|
||||
if self._cleanup_task is None:
|
||||
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the cleanup background task."""
|
||||
if self._cleanup_task:
|
||||
self._cleanup_task.cancel()
|
||||
try:
|
||||
await self._cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._cleanup_task = None
|
||||
|
||||
async def _cleanup_loop(self):
|
||||
"""Periodically clean up expired requests."""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(60) # Check every minute
|
||||
await self._cleanup_expired()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
logging.exception("Error in remote auth cleanup loop")
|
||||
|
||||
async def _cleanup_expired(self):
|
||||
"""Remove expired requests and notify waiting clients."""
|
||||
now = datetime.now(timezone.utc)
|
||||
expired_keys = []
|
||||
async with self._lock:
|
||||
for key, req in self._requests.items():
|
||||
if now > req.created_at + REMOTE_AUTH_LIFETIME:
|
||||
expired_keys.append(key)
|
||||
for key in expired_keys:
|
||||
req = self._requests.pop(key)
|
||||
if req.notify and not req.completed:
|
||||
try:
|
||||
req.notify(None, None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def create_request(
|
||||
self,
|
||||
host: str,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
action: str = "login",
|
||||
) -> tuple[str, datetime]:
|
||||
"""Create a new remote auth request.
|
||||
|
||||
The code is a 3-word passphrase.
|
||||
We ensure uniqueness across concurrent requests.
|
||||
|
||||
Returns:
|
||||
(code, expiry) - The 3-word passphrase code and expiration time
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
expiry = now + REMOTE_AUTH_LIFETIME
|
||||
|
||||
async with self._lock:
|
||||
# Generate unique 3-word code
|
||||
max_attempts = 100
|
||||
for _ in range(max_attempts):
|
||||
code = passphrase.generate(n=passphrase.N_WORDS_SHORT)
|
||||
if code not in self._requests:
|
||||
break
|
||||
else:
|
||||
# Extremely unlikely but handle gracefully
|
||||
raise ValueError("Unable to generate unique code")
|
||||
|
||||
request = RemoteAuthRequest(
|
||||
key=code,
|
||||
created_at=now,
|
||||
host=host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
action=action,
|
||||
)
|
||||
|
||||
self._requests[code] = request
|
||||
|
||||
return code, expiry
|
||||
|
||||
async def get_request(self, code: str) -> RemoteAuthRequest | None:
|
||||
"""Get a pending request by code, if valid and not expired."""
|
||||
# Normalize: lowercase, dot-separated words
|
||||
normalized = code.lower().strip().replace(" ", ".")
|
||||
if not passphrase.is_well_formed(normalized, n=passphrase.N_WORDS_SHORT):
|
||||
return None
|
||||
async with self._lock:
|
||||
req = self._requests.get(normalized)
|
||||
if req is None:
|
||||
return None
|
||||
now = datetime.now(timezone.utc)
|
||||
if now > req.created_at + REMOTE_AUTH_LIFETIME:
|
||||
# Expired
|
||||
del self._requests[normalized]
|
||||
return None
|
||||
return req
|
||||
|
||||
async def set_notify_callback(
|
||||
self,
|
||||
token: str,
|
||||
callback: Callable[[str | None, UUID | None, UUID | None, str | None], None],
|
||||
) -> bool:
|
||||
"""Set the notification callback for a request.
|
||||
|
||||
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.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 | None,
|
||||
user_uuid: UUID,
|
||||
credential_uuid: UUID,
|
||||
reset_token: str | None = None,
|
||||
) -> bool:
|
||||
"""Mark a request as completed with the authentication result.
|
||||
|
||||
The request is removed after notifying the waiting client.
|
||||
Returns True if the request existed and was completed.
|
||||
"""
|
||||
async with self._lock:
|
||||
req = self._requests.pop(token, None)
|
||||
if req is None:
|
||||
return False
|
||||
if req.notify:
|
||||
try:
|
||||
req.notify(session_token, user_uuid, credential_uuid, reset_token)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
async def cancel_request(
|
||||
self, token: str, *, denied: bool = False
|
||||
) -> RemoteAuthRequest | None:
|
||||
"""Cancel and remove a request.
|
||||
|
||||
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 None
|
||||
if denied:
|
||||
req.denied = True
|
||||
if req.notify and not req.completed:
|
||||
try:
|
||||
# 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 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)."""
|
||||
if not passphrase.is_well_formed(token, n=passphrase.N_WORDS_SHORT):
|
||||
return None
|
||||
async with self._lock:
|
||||
req = self._requests.get(token)
|
||||
if req is None:
|
||||
return None
|
||||
now = datetime.now(timezone.utc)
|
||||
if now > req.created_at + REMOTE_AUTH_LIFETIME:
|
||||
del self._requests[token]
|
||||
return None
|
||||
# Don't remove yet - wait until completion
|
||||
return req
|
||||
|
||||
|
||||
# Global instance
|
||||
instance: RemoteAuthManager | None = None
|
||||
|
||||
|
||||
async def init():
|
||||
"""Initialize the global remote auth manager."""
|
||||
global instance
|
||||
instance = RemoteAuthManager()
|
||||
await instance.start()
|
||||
|
||||
|
||||
async def shutdown():
|
||||
"""Shutdown the global remote auth manager."""
|
||||
global instance
|
||||
if instance:
|
||||
await instance.stop()
|
||||
instance = None
|
||||
@@ -8,7 +8,10 @@ import httpx
|
||||
|
||||
__all__ = ["path", "file", "read", "is_dev_mode"]
|
||||
|
||||
DEV_SERVER = "http://localhost:4403"
|
||||
|
||||
def _get_dev_server() -> str | None:
|
||||
"""Get the dev server URL from environment, or None if not in dev mode."""
|
||||
return os.environ.get("PASKIA_DEVMODE") or None
|
||||
|
||||
|
||||
def _resolve_static_dir() -> Path:
|
||||
@@ -34,7 +37,7 @@ def file(*parts: str) -> Path:
|
||||
|
||||
def is_dev_mode() -> bool:
|
||||
"""Check if we're running in dev mode (Vite frontend server)."""
|
||||
return os.environ.get("PASKIA_DEVMODE") == "1"
|
||||
return bool(_get_dev_server())
|
||||
|
||||
|
||||
async def read(filepath: str) -> tuple[bytes, int, dict[str, str]]:
|
||||
@@ -51,8 +54,9 @@ async def read(filepath: str) -> tuple[bytes, int, dict[str, str]]:
|
||||
FastAPI Response(*args) or Sanic raw response.
|
||||
"""
|
||||
if is_dev_mode():
|
||||
dev_server = _get_dev_server()
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(f"{DEV_SERVER}{filepath}")
|
||||
resp = await client.get(f"{dev_server}{filepath}")
|
||||
resp.raise_for_status()
|
||||
mime = resp.headers.get("content-type", "application/octet-stream")
|
||||
# Strip charset suffix if present
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -3,6 +3,7 @@ import secrets
|
||||
from paskia.util.wordlist import words
|
||||
|
||||
N_WORDS = 5
|
||||
N_WORDS_SHORT = 3
|
||||
|
||||
wset = set(words)
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Proof of Work utility using PBKDF2-SHA512.
|
||||
|
||||
The PoW requires finding nonces where PBKDF2(challenge, nonce) produces
|
||||
output with a zero first byte. Each work unit requires finding one such nonce.
|
||||
All valid nonces are concatenated into a solution for server verification.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
EASY = 2 # Around 0.25s
|
||||
NORMAL = 8 # Around 1s
|
||||
HARD = 32 # Around 4s
|
||||
|
||||
|
||||
def generate_challenge() -> bytes:
|
||||
"""Generate a random 8-byte challenge."""
|
||||
return secrets.token_bytes(8)
|
||||
|
||||
|
||||
def verify_pow(challenge: bytes, solution: bytes, work: int = NORMAL) -> None:
|
||||
"""Verify a Proof of Work solution.
|
||||
|
||||
Args:
|
||||
challenge: 8-byte server-provided challenge
|
||||
solution: Concatenated 8-byte nonces (8 * work bytes)
|
||||
work: Number of work units expected
|
||||
|
||||
Raises:
|
||||
ValueError: If the solution is invalid
|
||||
"""
|
||||
if len(challenge) != 8:
|
||||
raise ValueError("Invalid challenge length")
|
||||
|
||||
if len(solution) != 8 * work:
|
||||
raise ValueError("Invalid solution length")
|
||||
|
||||
# Verify each work unit - check that PBKDF2 output starts with 0x00
|
||||
for i in range(work):
|
||||
nonce = solution[i * 8 : (i + 1) * 8]
|
||||
# Require first byte of PBKDF2-SHA512 to be zero
|
||||
result = hashlib.pbkdf2_hmac("sha512", challenge, nonce, 128, 2)
|
||||
if result[0] or result[1] & 0x07:
|
||||
raise ValueError("Invalid PoW solution")
|
||||
@@ -1,7 +1,8 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
import base64url
|
||||
|
||||
from paskia.util.passphrase import is_well_formed
|
||||
|
||||
|
||||
@@ -12,21 +13,20 @@ def create_token() -> str:
|
||||
def session_key(token: str) -> bytes:
|
||||
if len(token) != 16:
|
||||
raise ValueError("Session token must be exactly 16 characters long")
|
||||
return b"sess" + base64.urlsafe_b64decode(token)
|
||||
return b"sess" + base64url.dec(token)
|
||||
|
||||
|
||||
def encode_session_key(key: bytes) -> str:
|
||||
"""Encode an opaque session key for external representation."""
|
||||
return base64.urlsafe_b64encode(key).decode().rstrip("=")
|
||||
return base64url.enc(key)
|
||||
|
||||
|
||||
def decode_session_key(encoded: str) -> bytes:
|
||||
"""Decode an opaque session key from its public representation."""
|
||||
if not encoded:
|
||||
raise ValueError("Invalid session identifier")
|
||||
padding = "=" * (-len(encoded) % 4)
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(encoded + padding)
|
||||
raw = base64url.dec(encoded)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
raise ValueError("Invalid session identifier") from exc
|
||||
if not raw.startswith(b"sess"):
|
||||
|
||||
@@ -79,6 +79,7 @@ dev = [
|
||||
"pytest>=9.0.1",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
"pytest-cov>=7.0.0",
|
||||
"ruff>=0.14.8",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
-156
@@ -1,156 +0,0 @@
|
||||
#!/usr/bin/env -S uv run
|
||||
"""Run Vite development server for frontend and FastAPI backend with auto-reload.
|
||||
|
||||
This script is only available when running from the git repository source,
|
||||
not from the installed package. It starts both the Vite frontend dev server
|
||||
and the FastAPI backend with auto-reload enabled.
|
||||
|
||||
Usage:
|
||||
uv run scripts/dev.py [host:port] [options...]
|
||||
|
||||
The optional host:port argument sets where the Vite frontend listens.
|
||||
All other options are forwarded to `paskia serve`.
|
||||
Backend always listens on localhost:4402.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from sys import stderr
|
||||
from threading import Thread
|
||||
|
||||
from paskia.fastapi.__main__ import parse_endpoint
|
||||
|
||||
DEFAULT_VITE_PORT = 4403 # overrides by CLI option
|
||||
BACKEND_PORT = 4402 # hardcoded, also in vite.config.ts
|
||||
|
||||
NO_FRONTEND_TOOL = """\
|
||||
┃ ⚠️ deno, npm or bunx needed to run the frontend server.
|
||||
"""
|
||||
|
||||
BUN_BUG = """\
|
||||
┃ ⚠️ Bun cannot correctly proxy API requests to the backend.
|
||||
┃ Bug report: https://github.com/oven-sh/bun/issues/9882
|
||||
┃
|
||||
┃ Options:
|
||||
┃ - sudo caddy run --config caddy/Caddyfile.dev
|
||||
┃ - Install deno or npm instead
|
||||
┃
|
||||
┃ Caddy will skip the Vite for API calls and serve everything at port 443.
|
||||
┃ Otherwise Vite serves at port 8077 and proxies to backend (broken with bun).
|
||||
"""
|
||||
|
||||
NO_FRONTEND = """\
|
||||
┃
|
||||
┃ The backend will still try reaching Vite at {vite_url}
|
||||
┃ for various frontend assets, so make sure to start it manually.
|
||||
"""
|
||||
|
||||
|
||||
def run_vite(vite_url: str, vite_host: str | None, vite_port: int):
|
||||
"""Spawn the frontend dev server (deno, npm, or bunx) as a background process."""
|
||||
devpath = Path(__file__).parent.parent / "frontend"
|
||||
if not (devpath / "package.json").exists():
|
||||
stderr.write(
|
||||
f"┃ ⚠️ Frontend source not found at {devpath}\n"
|
||||
+ NO_FRONTEND.format(vite_url=vite_url)
|
||||
)
|
||||
return
|
||||
|
||||
options = [
|
||||
("deno", "run", "dev"),
|
||||
("npm", "run", "dev", "--"),
|
||||
("bunx", "--bun", "vite"),
|
||||
]
|
||||
cmd = None
|
||||
tool_name = None
|
||||
for option in options:
|
||||
if tool := shutil.which(option[0]):
|
||||
cmd = [tool, *option[1:]]
|
||||
tool_name = option[0]
|
||||
break
|
||||
|
||||
# Add Vite CLI args for host/port
|
||||
vite_args = ["--port", str(vite_port)]
|
||||
if vite_host:
|
||||
vite_args.extend(["--host", vite_host])
|
||||
|
||||
vite_process = None
|
||||
|
||||
def start_vite():
|
||||
nonlocal vite_process
|
||||
if cmd is None:
|
||||
stderr.write(NO_FRONTEND_TOOL + NO_FRONTEND.format(vite_url=vite_url))
|
||||
return
|
||||
assert tool_name is not None
|
||||
try:
|
||||
if tool_name == "bunx":
|
||||
stderr.write(BUN_BUG)
|
||||
|
||||
full_cmd = cmd + vite_args
|
||||
stderr.write(f">>> {' '.join([tool_name, *full_cmd[1:]])}\n")
|
||||
vite_process = subprocess.Popen(full_cmd, cwd=str(devpath), shell=False)
|
||||
except Exception as e:
|
||||
stderr.write(
|
||||
f"┃ ⚠️ Vite couldn't start: {e}\n"
|
||||
+ NO_FRONTEND.format(vite_url=vite_url)
|
||||
)
|
||||
|
||||
def cleanup():
|
||||
if vite_process:
|
||||
vite_process.terminate()
|
||||
vite_process.wait()
|
||||
|
||||
# Start Vite in a separate thread
|
||||
vite_thread = Thread(target=start_vite, daemon=True)
|
||||
vite_thread.start()
|
||||
|
||||
atexit.register(cleanup)
|
||||
signal.signal(signal.SIGTERM, lambda *_: cleanup())
|
||||
signal.signal(signal.SIGINT, lambda *_: cleanup())
|
||||
|
||||
|
||||
def main():
|
||||
# Parse optional hostport argument for Vite frontend
|
||||
parser = argparse.ArgumentParser(add_help=False)
|
||||
parser.add_argument("hostport", nargs="?", default=None)
|
||||
args, remaining = parser.parse_known_args()
|
||||
|
||||
# Parse Vite endpoint
|
||||
vite_host, vite_port, vite_uds, all_ifaces = parse_endpoint(
|
||||
args.hostport, DEFAULT_VITE_PORT
|
||||
)
|
||||
|
||||
if vite_uds:
|
||||
raise SystemExit("┃ ⚠️ Unix sockets are not supported for Vite frontend")
|
||||
|
||||
# Handle all-interfaces case (:port syntax)
|
||||
# Vite uses 0.0.0.0 to listen on all interfaces (IPv4 only, sufficient for dev)
|
||||
if all_ifaces:
|
||||
vite_host = "0.0.0.0"
|
||||
|
||||
# Build Vite URL for PASKIA_DEVMODE (always use localhost for URL)
|
||||
vite_url = f"http://localhost:{vite_port}"
|
||||
|
||||
# Start Vite dev server
|
||||
run_vite(vite_url, vite_host, vite_port)
|
||||
|
||||
# Set dev mode with Vite URL
|
||||
os.environ["PASKIA_DEVMODE"] = vite_url
|
||||
|
||||
# Import CLI after environment is set up
|
||||
from paskia.fastapi.__main__ import main as cli_main
|
||||
|
||||
# Build argv for the main CLI in Dev mode
|
||||
# Backend always listens on localhost only (Vite proxies API requests)
|
||||
sys.argv = ["paskia", "serve", f"localhost:{BACKEND_PORT}"] + remaining
|
||||
cli_main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+463
@@ -0,0 +1,463 @@
|
||||
#!/usr/bin/env -S uv run
|
||||
"""Run Vite development server for frontend and FastAPI backend with auto-reload.
|
||||
|
||||
This script is only available when running from the git repository source,
|
||||
not from the installed package. It starts both the Vite frontend dev server
|
||||
and the FastAPI backend with auto-reload enabled.
|
||||
|
||||
Usage:
|
||||
uv run scripts/dev.py [host:port] [options...]
|
||||
|
||||
The optional host:port argument sets where the Vite frontend listens.
|
||||
All other options are forwarded to `paskia serve`.
|
||||
Backend always listens on localhost:4402.
|
||||
|
||||
Options:
|
||||
--caddy Run Caddy as HTTPS proxy on port 443 (requires sudo)
|
||||
--rp-id HOST Relying Party ID (used as hostname for Caddy)
|
||||
--origin URL Allowed origin(s), passed to backend
|
||||
--auth-host H Dedicated auth host, passed to backend
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from sys import stderr
|
||||
from threading import Thread
|
||||
from urllib.parse import urlparse
|
||||
|
||||
DEFAULT_VITE_PORT = 4403 # overrides by CLI option
|
||||
BACKEND_PORT = 4402 # hardcoded, also in vite.config.ts
|
||||
CADDY_PORT = 443 # HTTPS port for Caddy proxy
|
||||
CADDY_HTTP_PORT = 80 # HTTP port for ACME challenges
|
||||
DEFAULT_HOST = "localhost"
|
||||
|
||||
NO_FRONTEND_TOOL = """\
|
||||
┃ ⚠️ deno, npm or bunx needed to run the frontend server.
|
||||
"""
|
||||
|
||||
BUN_BUG = """\
|
||||
┃ ⚠️ Bun cannot correctly proxy API requests to the backend.
|
||||
┃ Bug report: https://github.com/oven-sh/bun/issues/9882
|
||||
┃
|
||||
┃ Options:
|
||||
┃ - sudo caddy run --config caddy/Caddyfile.dev
|
||||
┃ - Install deno or npm instead
|
||||
┃
|
||||
┃ Caddy will skip the Vite for API calls and serve everything at port 443.
|
||||
┃ Otherwise Vite serves at port 8077 and proxies to backend (broken with bun).
|
||||
"""
|
||||
|
||||
NO_FRONTEND = """\
|
||||
┃
|
||||
┃ The backend will still try reaching Vite at {vite_url}
|
||||
┃ for various frontend assets, so make sure to start it manually.
|
||||
"""
|
||||
|
||||
CADDYFILE_SITE_BLOCK = """\
|
||||
SITE_ADDR {
|
||||
# WebSockets bypass directly to backend (workaround for bun proxy bug)
|
||||
handle /auth/ws/* {
|
||||
reverse_proxy localhost:BACKEND_PORT
|
||||
}
|
||||
# Everything else goes to or via Vite
|
||||
handle {
|
||||
reverse_proxy localhost:VITE_PORT
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def parse_endpoint(
|
||||
value: str | None, default_port: int
|
||||
) -> tuple[str | None, int | None, str | None, bool]:
|
||||
"""Parse an endpoint for Vite (simplified version for dev.py).
|
||||
|
||||
Returns (host, port, uds_path, all_ifaces).
|
||||
"""
|
||||
if not value:
|
||||
return DEFAULT_HOST, default_port, None, False
|
||||
|
||||
# Port only (numeric) -> localhost:port
|
||||
if value.isdigit():
|
||||
return DEFAULT_HOST, int(value), None, False
|
||||
|
||||
# Leading colon :port -> bind all interfaces
|
||||
if value.startswith(":") and value != ":":
|
||||
port_part = value[1:]
|
||||
if not port_part.isdigit():
|
||||
raise SystemExit(f"Invalid port in '{value}'")
|
||||
return None, int(port_part), None, True
|
||||
|
||||
# UNIX domain socket
|
||||
if value.startswith("unix:"):
|
||||
uds_path = value[5:] or None
|
||||
if uds_path is None:
|
||||
raise SystemExit("unix: path must not be empty")
|
||||
return None, None, uds_path, False
|
||||
|
||||
# Unbracketed IPv6 (cannot safely contain a port)
|
||||
if value.count(":") > 1 and not value.startswith("["):
|
||||
try:
|
||||
ipaddress.IPv6Address(value)
|
||||
except ValueError as e:
|
||||
raise SystemExit(f"Invalid IPv6 address '{value}': {e}")
|
||||
return value, default_port, None, False
|
||||
|
||||
# Use urllib.parse for everything else
|
||||
parsed = urlparse(f"//{value}")
|
||||
host = parsed.hostname or DEFAULT_HOST
|
||||
port = parsed.port or default_port
|
||||
|
||||
return host, port, None, False
|
||||
|
||||
|
||||
def run_vite(vite_url: str, vite_host: str | None, vite_port: int):
|
||||
"""Spawn the frontend dev server (deno, npm, or bunx) as a background process."""
|
||||
devpath = Path(__file__).parent.parent / "frontend"
|
||||
if not (devpath / "package.json").exists():
|
||||
stderr.write(
|
||||
f"┃ ⚠️ Frontend source not found at {devpath}\n"
|
||||
+ NO_FRONTEND.format(vite_url=vite_url)
|
||||
)
|
||||
return
|
||||
|
||||
options = [
|
||||
("deno", "run", "dev"),
|
||||
("npm", "--silent", "run", "dev", "--"),
|
||||
("bunx", "--bun", "vite"),
|
||||
]
|
||||
cmd = None
|
||||
tool_name = None
|
||||
for option in options:
|
||||
if tool := shutil.which(option[0]):
|
||||
cmd = [tool, *option[1:]]
|
||||
tool_name = option[0]
|
||||
break
|
||||
|
||||
# Add Vite CLI args for host/port
|
||||
vite_args = ["--port", str(vite_port), "--logLevel", "silent"]
|
||||
if vite_host:
|
||||
vite_args.extend(["--host", vite_host])
|
||||
|
||||
vite_process = None
|
||||
|
||||
def start_vite():
|
||||
nonlocal vite_process
|
||||
if cmd is None:
|
||||
stderr.write(NO_FRONTEND_TOOL + NO_FRONTEND.format(vite_url=vite_url))
|
||||
return
|
||||
assert tool_name is not None
|
||||
try:
|
||||
if tool_name == "bunx":
|
||||
stderr.write(BUN_BUG)
|
||||
|
||||
full_cmd = cmd + vite_args
|
||||
stderr.write(f">>> {' '.join([tool_name, *full_cmd[1:]])}\n")
|
||||
vite_process = subprocess.Popen(full_cmd, cwd=str(devpath), shell=False)
|
||||
except Exception as e:
|
||||
stderr.write(
|
||||
f"┃ ⚠️ Vite couldn't start: {e}\n"
|
||||
+ NO_FRONTEND.format(vite_url=vite_url)
|
||||
)
|
||||
|
||||
def cleanup():
|
||||
if vite_process:
|
||||
vite_process.terminate()
|
||||
vite_process.wait()
|
||||
|
||||
# Start Vite in a separate thread
|
||||
vite_thread = Thread(target=start_vite, daemon=True)
|
||||
vite_thread.start()
|
||||
|
||||
atexit.register(cleanup)
|
||||
signal.signal(signal.SIGTERM, lambda *_: cleanup())
|
||||
signal.signal(signal.SIGINT, lambda *_: cleanup())
|
||||
|
||||
|
||||
def run_caddy(origins: list[str], vite_port: int) -> subprocess.Popen | None:
|
||||
"""Spawn Caddy as HTTPS reverse proxy for the given origins."""
|
||||
caddy_path = shutil.which("caddy")
|
||||
if not caddy_path:
|
||||
stderr.write("┃ ⚠️ Caddy not found. Install it to use --caddy option.\n")
|
||||
return None
|
||||
|
||||
# Build Caddyfile with a site block for each origin
|
||||
caddyfile_parts = []
|
||||
for origin in origins:
|
||||
parsed = urlparse(origin)
|
||||
# Extract scheme://host:port from origin URL
|
||||
scheme = parsed.scheme or "https"
|
||||
host = parsed.hostname or parsed.path # handle case without scheme
|
||||
port = parsed.port or (CADDY_HTTP_PORT if scheme == "http" else CADDY_PORT)
|
||||
# Use standard ports without explicit port in address (cleaner URLs)
|
||||
if port in (80, 443):
|
||||
site_addr = f"{scheme}://{host}"
|
||||
else:
|
||||
site_addr = f"{scheme}://{host}:{port}"
|
||||
block = (
|
||||
CADDYFILE_SITE_BLOCK.replace("SITE_ADDR", site_addr)
|
||||
.replace("BACKEND_PORT", str(BACKEND_PORT))
|
||||
.replace("VITE_PORT", str(vite_port))
|
||||
)
|
||||
caddyfile_parts.append(block)
|
||||
|
||||
caddyfile = "\n".join(caddyfile_parts)
|
||||
caddy_process = None
|
||||
|
||||
try:
|
||||
# Use sudo to bind to privileged ports (80/443) for ACME certificate fetching
|
||||
cmd = ["sudo", caddy_path, "run", "--config", "-", "--adapter", "caddyfile"]
|
||||
caddy_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
caddy_process.stdin.write(caddyfile.encode())
|
||||
caddy_process.stdin.close()
|
||||
except Exception as e:
|
||||
stderr.write(f"┃ ⚠️ Caddy couldn't start: {e}\n")
|
||||
return None
|
||||
|
||||
# Helper to parse Caddy log line (JSON or plain text) into (level, logger, msg)
|
||||
def parse_caddy_log(line: str) -> tuple[str, str, str] | None:
|
||||
"""Parse a Caddy log line, return (level, logger, msg) or None if unparseable."""
|
||||
line = line.rstrip("\n")
|
||||
if not line:
|
||||
return None
|
||||
|
||||
# Try JSON format first
|
||||
try:
|
||||
log = json.loads(line)
|
||||
return (
|
||||
log.get("level", ""),
|
||||
log.get("logger", ""),
|
||||
log.get("msg", ""),
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Plain text format: "2025/12/06 22:59:41.390 INFO logger msg..."
|
||||
# or "2025/12/06 22:59:41.390 INFO msg..." (no logger)
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 2:
|
||||
# First part is "timestamp LEVEL", rest are logger and/or message
|
||||
first = parts[0].rsplit(None, 1) # split off the level from timestamp
|
||||
if len(first) == 2:
|
||||
level = first[1].lower()
|
||||
if len(parts) == 2:
|
||||
return (level, "", parts[1])
|
||||
else:
|
||||
return (level, parts[1], "\t".join(parts[2:]))
|
||||
|
||||
# Unparseable - return as-is with no level/logger
|
||||
return ("", "", line)
|
||||
|
||||
def strip_caddy_verbose(msg: str) -> str:
|
||||
"""Remove verbose prefixes from Caddy error messages."""
|
||||
return msg.replace("loading initial config: loading new config: ", "")
|
||||
|
||||
def format_caddy_log(level: str, logger: str, msg: str) -> str:
|
||||
"""Format a parsed Caddy log for display."""
|
||||
msg = strip_caddy_verbose(msg)
|
||||
if logger:
|
||||
return f"┃ [{level.upper()}] {logger}: {msg}\n"
|
||||
else:
|
||||
return f"┃ [{level.upper()}] {msg}\n"
|
||||
|
||||
# Read stderr line by line until Caddy signals it's ready or exits
|
||||
# Caddy outputs logs; "serving initial configuration" means it's ready
|
||||
while True:
|
||||
exit_code = caddy_process.poll()
|
||||
if exit_code is not None:
|
||||
# Process exited - read remaining stderr and report failure
|
||||
remaining = (
|
||||
caddy_process.stderr.read().decode() if caddy_process.stderr else ""
|
||||
)
|
||||
if remaining:
|
||||
for line in remaining.splitlines():
|
||||
if line:
|
||||
parsed = parse_caddy_log(line)
|
||||
if parsed:
|
||||
level, logger, msg = parsed
|
||||
if level:
|
||||
stderr.write(format_caddy_log(level, logger, msg))
|
||||
else:
|
||||
stderr.write(f"┃ {strip_caddy_verbose(msg)}\n")
|
||||
else:
|
||||
stderr.write(f"┃ {strip_caddy_verbose(line)}\n")
|
||||
stderr.write(f"┃ ⚠️ Caddy startup failed (exit code {exit_code})\n")
|
||||
return None
|
||||
|
||||
# Read one line from stderr (blocks until data available)
|
||||
line = caddy_process.stderr.readline().decode()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Check for ready signal
|
||||
if "serving initial configuration" in line:
|
||||
break
|
||||
|
||||
parsed = parse_caddy_log(line)
|
||||
if not parsed:
|
||||
continue
|
||||
|
||||
level, logger, msg = parsed
|
||||
|
||||
# Filter out info-level and admin messages
|
||||
if level == "info" or logger == "admin":
|
||||
continue
|
||||
|
||||
# Show errors/fatal to user
|
||||
if level in ("error", "fatal"):
|
||||
stderr.write(format_caddy_log(level, logger, msg))
|
||||
elif not level:
|
||||
# Unparseable non-empty line (e.g., sudo prompt) - pass through with prefix
|
||||
stderr.write(f"┃ {strip_caddy_verbose(msg)}\n")
|
||||
stderr.flush()
|
||||
|
||||
# Start a background thread to drain stderr and show errors
|
||||
def drain_stderr():
|
||||
while True:
|
||||
line = caddy_process.stderr.readline().decode()
|
||||
if not line:
|
||||
break
|
||||
|
||||
parsed = parse_caddy_log(line)
|
||||
if not parsed:
|
||||
continue
|
||||
|
||||
level, logger, msg = parsed
|
||||
|
||||
# Filter out info-level and admin messages
|
||||
if level == "info" or logger == "admin":
|
||||
continue
|
||||
|
||||
# Show errors/warnings to user
|
||||
if level in ("error", "fatal", "warn"):
|
||||
stderr.write(format_caddy_log(level, logger, msg))
|
||||
elif not level:
|
||||
# Unparseable line - pass through with prefix
|
||||
stderr.write(f"┃ {strip_caddy_verbose(msg)}\n")
|
||||
|
||||
drain_thread = Thread(target=drain_stderr, daemon=True)
|
||||
drain_thread.start()
|
||||
|
||||
def cleanup():
|
||||
if caddy_process:
|
||||
caddy_process.terminate()
|
||||
caddy_process.wait()
|
||||
|
||||
atexit.register(cleanup)
|
||||
|
||||
return caddy_process
|
||||
|
||||
|
||||
def main():
|
||||
# Parse optional hostport argument for Vite frontend
|
||||
parser = argparse.ArgumentParser(add_help=False)
|
||||
parser.add_argument("hostport", nargs="?", default=None)
|
||||
parser.add_argument("--caddy", action="store_true", help="Run Caddy as HTTPS proxy")
|
||||
parser.add_argument("--rp-id", default="localhost", help="Relying Party ID")
|
||||
parser.add_argument(
|
||||
"--origin", action="append", dest="origins", help="Allowed origin(s)"
|
||||
)
|
||||
parser.add_argument("--auth-host", help="Dedicated auth host")
|
||||
args, remaining = parser.parse_known_args()
|
||||
|
||||
# Parse Vite endpoint
|
||||
vite_host, vite_port, vite_uds, all_ifaces = parse_endpoint(
|
||||
args.hostport, DEFAULT_VITE_PORT
|
||||
)
|
||||
|
||||
if vite_uds:
|
||||
raise SystemExit("┃ ⚠️ Unix sockets are not supported for Vite frontend")
|
||||
|
||||
# Handle all-interfaces case (:port syntax)
|
||||
# Vite uses 0.0.0.0 to listen on all interfaces (IPv4 only, sufficient for dev)
|
||||
if all_ifaces:
|
||||
vite_host = "0.0.0.0"
|
||||
|
||||
# Build Vite URL for PASKIA_DEVMODE (always use localhost for URL)
|
||||
vite_url = f"http://localhost:{vite_port}"
|
||||
|
||||
# Compute origins for Caddy (user-specified or auto-generated)
|
||||
caddy_origins = []
|
||||
if args.origins:
|
||||
# User specified explicit origins - use those
|
||||
caddy_origins = args.origins
|
||||
elif args.caddy:
|
||||
# Caddy mode without explicit origins: add https origin for the hostname
|
||||
if args.auth_host:
|
||||
# auth-host is the primary origin
|
||||
auth_host = args.auth_host
|
||||
if "://" not in auth_host:
|
||||
auth_host = f"https://{auth_host}"
|
||||
caddy_origins.append(auth_host)
|
||||
else:
|
||||
# Use rp-id as the hostname (standard port 443, no port in URL)
|
||||
caddy_origins.append(f"https://{args.rp_id}")
|
||||
|
||||
# Start Caddy if requested (after computing origins)
|
||||
if args.caddy:
|
||||
if not caddy_origins:
|
||||
caddy_origins = [f"https://{args.rp_id}"]
|
||||
stderr.write(f">>> sudo caddy @ {' '.join(caddy_origins)}\n")
|
||||
if not run_caddy(caddy_origins, vite_port):
|
||||
raise SystemExit(1)
|
||||
|
||||
# Start Vite dev server
|
||||
run_vite(vite_url, vite_host, vite_port)
|
||||
|
||||
# Set dev mode with Vite URL in environment for subprocess
|
||||
env = os.environ.copy()
|
||||
env["PASKIA_DEVMODE"] = vite_url
|
||||
|
||||
# Build command with origin args
|
||||
cmd = ["paskia", "serve", f"localhost:{BACKEND_PORT}"]
|
||||
|
||||
# Pass through rp-id (always pass, has default)
|
||||
cmd.extend(["--rp-id", args.rp_id])
|
||||
|
||||
# Pass through auth-host if specified
|
||||
if args.auth_host:
|
||||
cmd.extend(["--auth-host", args.auth_host])
|
||||
|
||||
# Collect all origins: Caddy origins first (auth-host first), then user origins
|
||||
# Use a set to track and avoid duplicates
|
||||
all_origins = []
|
||||
seen_origins = set(args.origins) if args.origins else set()
|
||||
|
||||
# Add Caddy origins first (they include auth-host origin if configured)
|
||||
if args.caddy:
|
||||
for origin in caddy_origins:
|
||||
if origin not in seen_origins:
|
||||
all_origins.append(origin)
|
||||
seen_origins.add(origin)
|
||||
|
||||
# Add user-specified origins
|
||||
if args.origins:
|
||||
for origin in args.origins:
|
||||
if origin not in seen_origins:
|
||||
all_origins.append(origin)
|
||||
seen_origins.add(origin)
|
||||
|
||||
# Pass all origins to backend
|
||||
for origin in all_origins:
|
||||
cmd.extend(["--origin", origin])
|
||||
|
||||
# Add remaining args (ones we didn't parse)
|
||||
cmd.extend(remaining)
|
||||
|
||||
stderr.write(f">>> (devmode) {' '.join(cmd)}\n")
|
||||
subprocess.run(cmd, env=env)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user