Implement restricted-api for JS-driven auth calls, examples added (WIP!). Layout and styling simplified.
This commit is contained in:
@@ -1,22 +1,20 @@
|
||||
<template>
|
||||
<section class="view-root view-device-link">
|
||||
<div class="view-content view-content--narrow">
|
||||
<header class="view-header">
|
||||
<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="authStore.currentView = 'profile'" class="btn-secondary">Back to Profile</button>
|
||||
</div>
|
||||
<section class="view-root view-root--narrow view-device-link">
|
||||
<header class="view-header">
|
||||
<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="authStore.currentView = 'profile'" class="btn-secondary">Back to Profile</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -43,10 +41,6 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-content--narrow {
|
||||
max-width: 540px;
|
||||
}
|
||||
|
||||
.view-lede {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
@@ -17,7 +17,7 @@ defineEmits(['close'])
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
background: #334155;
|
||||
backdrop-filter: blur(.1rem);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -80,7 +80,7 @@ defineEmits(['close'])
|
||||
.modal :deep(.modal-form textarea:focus) {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
||||
box-shadow: 0 0 0 2px #c7d2fe;
|
||||
}
|
||||
|
||||
.modal :deep(.modal-actions) {
|
||||
@@ -90,4 +90,4 @@ defineEmits(['close'])
|
||||
margin-top: var(--space-md);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,92 +1,90 @@
|
||||
<template>
|
||||
<section class="view-root" data-view="profile">
|
||||
<div class="view-content">
|
||||
<header class="view-header">
|
||||
<h1>👋 Welcome!</h1>
|
||||
<Breadcrumbs :entries="breadcrumbEntries" />
|
||||
<p class="view-lede">Manage your account details and passkeys.</p>
|
||||
</header>
|
||||
<header class="view-header">
|
||||
<h1>👋 Welcome!</h1>
|
||||
<Breadcrumbs :entries="breadcrumbEntries" />
|
||||
<p class="view-lede">Manage your account details and passkeys.</p>
|
||||
</header>
|
||||
|
||||
<section class="section-block">
|
||||
<UserBasicInfo
|
||||
v-if="authStore.userInfo?.user"
|
||||
:name="authStore.userInfo.user.user_name"
|
||||
:visits="authStore.userInfo.user.visits || 0"
|
||||
:created-at="authStore.userInfo.user.created_at"
|
||||
:last-seen="authStore.userInfo.user.last_seen"
|
||||
<section class="section-block">
|
||||
<UserBasicInfo
|
||||
v-if="authStore.userInfo?.user"
|
||||
:name="authStore.userInfo.user.user_name"
|
||||
:visits="authStore.userInfo.user.visits || 0"
|
||||
:created-at="authStore.userInfo.user.created_at"
|
||||
:last-seen="authStore.userInfo.user.last_seen"
|
||||
:loading="authStore.isLoading"
|
||||
update-endpoint="/auth/api/user/display-name"
|
||||
@saved="authStore.loadUserInfo()"
|
||||
@edit-name="openNameDialog"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="section-block">
|
||||
<div class="section-header">
|
||||
<h2>Your Passkeys</h2>
|
||||
<p class="section-description">Keep at least one trusted passkey so you can always sign in.</p>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<CredentialList
|
||||
:credentials="authStore.userInfo?.credentials || []"
|
||||
:aaguid-info="authStore.userInfo?.aaguid_info || {}"
|
||||
:loading="authStore.isLoading"
|
||||
update-endpoint="/auth/api/user/display-name"
|
||||
@saved="authStore.loadUserInfo()"
|
||||
@edit-name="openNameDialog"
|
||||
allow-delete
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="section-block">
|
||||
<div class="section-header">
|
||||
<h2>Your Passkeys</h2>
|
||||
<p class="section-description">Keep at least one trusted passkey so you can always sign in.</p>
|
||||
<div class="button-row">
|
||||
<button @click="addNewCredential" class="btn-primary">Add New Passkey</button>
|
||||
<button @click="showRegLink = true" class="btn-secondary">Add Another Device</button>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<CredentialList
|
||||
:credentials="authStore.userInfo?.credentials || []"
|
||||
:aaguid-info="authStore.userInfo?.aaguid_info || {}"
|
||||
:loading="authStore.isLoading"
|
||||
allow-delete
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
<div class="button-row">
|
||||
<button @click="addNewCredential" class="btn-primary">Add New Passkey</button>
|
||||
<button @click="showRegLink = true" class="btn-secondary">Add Another Device</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SessionList
|
||||
:sessions="sessions"
|
||||
:terminating-sessions="terminatingSessions"
|
||||
@terminate="terminateSession"
|
||||
section-description="Review where you're signed in and end any sessions you no longer recognize."
|
||||
/>
|
||||
<SessionList
|
||||
:sessions="sessions"
|
||||
:terminating-sessions="terminatingSessions"
|
||||
@terminate="terminateSession"
|
||||
section-description="Review where you're signed in and end any sessions you no longer recognize."
|
||||
/>
|
||||
|
||||
<Modal v-if="showNameDialog" @close="showNameDialog = false">
|
||||
<h3>Edit Display Name</h3>
|
||||
<form @submit.prevent="saveName" class="modal-form">
|
||||
<NameEditForm
|
||||
label="Display Name"
|
||||
v-model="newName"
|
||||
:busy="saving"
|
||||
@cancel="showNameDialog = false"
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
<Modal v-if="showNameDialog" @close="showNameDialog = false">
|
||||
<h3>Edit Display Name</h3>
|
||||
<form @submit.prevent="saveName" class="modal-form">
|
||||
<NameEditForm
|
||||
label="Display Name"
|
||||
v-model="newName"
|
||||
:busy="saving"
|
||||
@cancel="showNameDialog = false"
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<section class="section-block">
|
||||
<div class="button-row logout-row" :class="{ single: !hasMultipleSessions }">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
@click="history.back()"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button v-if="!hasMultipleSessions" @click="logoutEverywhere" class="btn-danger logout-button" :disabled="authStore.isLoading">Logout</button>
|
||||
<template v-else>
|
||||
<button @click="logout" class="btn-danger logout-button" :disabled="authStore.isLoading">Logout</button>
|
||||
<button @click="logoutEverywhere" class="btn-danger logout-button" :disabled="authStore.isLoading">All</button>
|
||||
</template>
|
||||
</div>
|
||||
<p class="logout-note" v-if="!hasMultipleSessions"><strong>Logout</strong> from {{ currentSessionHost }}.</p>
|
||||
<p class="logout-note" v-else><strong>Logout</strong> this session on {{ currentSessionHost }}, or <strong>All</strong> sessions across all sites and devices for {{ rpName }}. You'll need to log in again with your passkey afterwards.</p>
|
||||
</section>
|
||||
<RegistrationLinkModal
|
||||
v-if="showRegLink"
|
||||
:endpoint="'/auth/api/user/create-link'"
|
||||
:auto-copy="false"
|
||||
:prefix-copy-with-user-name="false"
|
||||
@close="showRegLink = false"
|
||||
@copied="showRegLink = false; authStore.showMessage('Link copied to clipboard!', 'success', 2500)"
|
||||
/>
|
||||
</div>
|
||||
<section class="section-block">
|
||||
<div class="button-row logout-row" :class="{ single: !hasMultipleSessions }">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
@click="history.back()"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button v-if="!hasMultipleSessions" @click="logoutEverywhere" class="btn-danger logout-button" :disabled="authStore.isLoading">Logout</button>
|
||||
<template v-else>
|
||||
<button @click="logout" class="btn-danger logout-button" :disabled="authStore.isLoading">Logout</button>
|
||||
<button @click="logoutEverywhere" class="btn-danger logout-button" :disabled="authStore.isLoading">All</button>
|
||||
</template>
|
||||
</div>
|
||||
<p class="logout-note" v-if="!hasMultipleSessions"><strong>Logout</strong> from {{ currentSessionHost }}.</p>
|
||||
<p class="logout-note" v-else><strong>Logout</strong> this session on {{ currentSessionHost }}, or <strong>All</strong> sessions across all sites and devices for {{ rpName }}. You'll need to log in again with your passkey afterwards.</p>
|
||||
</section>
|
||||
<RegistrationLinkModal
|
||||
v-if="showRegLink"
|
||||
:endpoint="'/auth/api/user/create-link'"
|
||||
:auto-copy="false"
|
||||
:prefix-copy-with-user-name="false"
|
||||
@close="showRegLink = false"
|
||||
@copied="showRegLink = false; authStore.showMessage('Link copied to clipboard!', 'success', 2500)"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -196,4 +194,3 @@ const saveName = async () => {
|
||||
.logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; }
|
||||
@media (max-width: 720px) { .logout-button { width: 100%; } }
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<div v-if="status.show" class="global-status" style="display: block;">
|
||||
<div :class="['status', status.type]">
|
||||
{{ status.message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="view-root">
|
||||
<div v-if="!initializing" class="surface surface--tight">
|
||||
<header class="view-header center">
|
||||
<h1>{{ headingTitle }}</h1>
|
||||
<p v-if="isAuthenticated" class="user-line">👤 {{ userDisplayName }}</p>
|
||||
<p class="view-lede">{{ 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="$emit('home')">Profile</button>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import passkey from '@/utils/passkey'
|
||||
import { getSettings } from '@/utils/settings'
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'login',
|
||||
validator: (value) => ['login', 'reauth'].includes(value)
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['authenticated', 'forbidden', 'logout', 'back', 'home', 'auth-error'])
|
||||
|
||||
const status = reactive({ show: false, message: '', type: 'info' })
|
||||
const initializing = ref(true)
|
||||
const loading = ref(false)
|
||||
const settings = ref(null)
|
||||
const userInfo = ref(null)
|
||||
let statusTimer = null
|
||||
|
||||
const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
|
||||
|
||||
const canAuthenticate = computed(() => {
|
||||
if (initializing.value) return false
|
||||
// In reauth mode, allow authentication even if already authenticated
|
||||
if (props.mode === 'reauth') return true
|
||||
// In login mode, only allow if not authenticated
|
||||
return !isAuthenticated.value
|
||||
})
|
||||
|
||||
const headingTitle = computed(() => {
|
||||
if (props.mode === 'reauth') {
|
||||
return `🔐 Additional Verification Required`
|
||||
}
|
||||
if (!isAuthenticated.value) return `🔐 ${settings.value?.rp_name || location.origin}`
|
||||
return '🚫 Forbidden'
|
||||
})
|
||||
|
||||
const headerMessage = computed(() => {
|
||||
if (props.mode === 'reauth') {
|
||||
return 'Please verify your identity to continue with this action.'
|
||||
}
|
||||
if (!isAuthenticated.value) return 'Please sign in to access this page.'
|
||||
return 'You lack the permissions required to access this page.'
|
||||
})
|
||||
|
||||
const userDisplayName = computed(() => userInfo.value?.user?.user_name || 'User')
|
||||
|
||||
function showMessage(message, type = 'info', duration = 3000) {
|
||||
status.show = true
|
||||
status.message = message
|
||||
status.type = type
|
||||
if (statusTimer) clearTimeout(statusTimer)
|
||||
if (duration > 0) statusTimer = setTimeout(() => { status.show = false }, duration)
|
||||
}
|
||||
|
||||
async function fetchSettings() {
|
||||
try {
|
||||
const data = await getSettings()
|
||||
settings.value = data
|
||||
if (data?.rp_name) {
|
||||
const titleSuffix = props.mode === 'reauth'
|
||||
? 'Verify Identity'
|
||||
: (isAuthenticated.value ? 'Forbidden' : 'Sign In')
|
||||
document.title = `${data.rp_name} · ${titleSuffix}`
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Unable to load settings', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUserInfo() {
|
||||
try {
|
||||
const res = await fetch('/auth/api/user-info', { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
const payload = await safeParseJson(res)
|
||||
showMessage(payload.detail || 'Unable to load user session info.', 'error', 2000)
|
||||
return
|
||||
}
|
||||
userInfo.value = await res.json()
|
||||
// In login mode, if the user is authenticated but still here, they lack permissions.
|
||||
// In reauth mode, being authenticated is expected - we just need re-verification.
|
||||
if (isAuthenticated.value && props.mode !== 'reauth') {
|
||||
showMessage('Permission Denied', 'error', 2000)
|
||||
emit('forbidden', userInfo.value)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load user info', error)
|
||||
showMessage('Could not contact the authentication server', 'error', 2000)
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticateUser() {
|
||||
if (!canAuthenticate.value || loading.value) return
|
||||
loading.value = true
|
||||
showMessage('Starting authentication…', 'info')
|
||||
let result
|
||||
try { result = await passkey.authenticate() } catch (error) {
|
||||
loading.value = false
|
||||
const message = error?.message || 'Passkey authentication cancelled'
|
||||
const cancelled = message === 'Passkey authentication cancelled'
|
||||
showMessage(cancelled ? message : `Authentication failed: ${message}`, cancelled ? 'info' : 'error', 4000)
|
||||
emit('auth-error', { message, cancelled })
|
||||
return
|
||||
}
|
||||
try { await setSessionCookie(result.session_token) } catch (error) {
|
||||
loading.value = false
|
||||
const message = error?.message || 'Failed to establish session'
|
||||
showMessage(message, 'error', 4000)
|
||||
emit('auth-error', { message, cancelled: false })
|
||||
return
|
||||
}
|
||||
loading.value = false
|
||||
emit('authenticated', result)
|
||||
}
|
||||
|
||||
async function logoutUser() {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try { await fetch('/auth/api/logout', { method: 'POST' }) } catch (_) { /* ignore */ }
|
||||
finally { loading.value = false }
|
||||
emit('logout')
|
||||
}
|
||||
|
||||
async function setSessionCookie(sessionToken) {
|
||||
const response = await fetch('/auth/api/set-session', {
|
||||
method: 'POST', headers: { Authorization: `Bearer ${sessionToken}` }
|
||||
})
|
||||
const payload = await safeParseJson(response)
|
||||
if (!response.ok || payload?.detail) throw new Error(payload?.detail || 'Session could not be established.')
|
||||
return payload
|
||||
}
|
||||
|
||||
async function safeParseJson(response) { try { return await response.json() } catch (_) { return null } }
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchSettings()
|
||||
await fetchUserInfo()
|
||||
initializing.value = false
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
showMessage,
|
||||
isAuthenticated,
|
||||
userInfo
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.button-row.center { display: flex; justify-content: center; gap: 0.75rem; }
|
||||
.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;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user