Implement restricted-api for JS-driven auth calls, examples added (WIP!). Layout and styling simplified.
This commit is contained in:
@@ -56,7 +56,7 @@ const permissionSummary = computed(() => {
|
||||
for (const o of orgs.value) {
|
||||
const orgBase = { uuid: o.uuid, display_name: o.display_name }
|
||||
const orgPerms = new Set(o.permissions || [])
|
||||
|
||||
|
||||
// Org-level permissions (direct) - only count if org can grant them
|
||||
for (const pid of o.permissions || []) {
|
||||
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
|
||||
@@ -65,13 +65,13 @@ const permissionSummary = computed(() => {
|
||||
summary[pid].orgSet.add(o.uuid)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Role-based permissions (inheritance) - only count if org can grant them
|
||||
for (const r of o.roles) {
|
||||
for (const pid of r.permissions) {
|
||||
// Only count if the org can grant this permission
|
||||
if (!orgPerms.has(pid)) continue
|
||||
|
||||
|
||||
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
|
||||
if (!summary[pid].orgSet.has(o.uuid)) {
|
||||
summary[pid].orgs.push(orgBase)
|
||||
@@ -253,14 +253,14 @@ function deleteRole(role) {
|
||||
|
||||
async function toggleRolePermission(role, pid, checked) {
|
||||
// Calculate new permissions array
|
||||
const newPermissions = checked
|
||||
? [...role.permissions, pid]
|
||||
const newPermissions = checked
|
||||
? [...role.permissions, pid]
|
||||
: role.permissions.filter(p => p !== pid)
|
||||
|
||||
|
||||
// Optimistic update
|
||||
const prevPermissions = [...role.permissions]
|
||||
role.permissions = newPermissions
|
||||
|
||||
|
||||
try {
|
||||
const res = await fetch(`/auth/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, {
|
||||
method: 'PUT',
|
||||
@@ -450,7 +450,7 @@ async function submitDialog() {
|
||||
const newDisplay = dialog.value.data.display_name?.trim()
|
||||
if (!newDisplay) throw new Error('Display name required')
|
||||
if (!newId) throw new Error('ID required')
|
||||
|
||||
|
||||
if (newId !== permission.id) {
|
||||
// ID changed, use rename endpoint
|
||||
const body = { old_id: permission.id, new_id: newId, display_name: newDisplay }
|
||||
@@ -484,76 +484,74 @@ async function submitDialog() {
|
||||
<div class="app-shell admin-shell">
|
||||
<StatusMessage />
|
||||
<main class="app-main">
|
||||
<section class="view-root view-admin">
|
||||
<div class="view-content view-content--wide">
|
||||
<header class="view-header">
|
||||
<h1>{{ pageHeading }}</h1>
|
||||
<Breadcrumbs :entries="breadcrumbEntries" />
|
||||
</header>
|
||||
<section class="view-root view-root--wide view-admin">
|
||||
<header class="view-header">
|
||||
<h1>{{ pageHeading }}</h1>
|
||||
<Breadcrumbs :entries="breadcrumbEntries" />
|
||||
</header>
|
||||
|
||||
<section class="section-block admin-section">
|
||||
<div class="section-body admin-section-body">
|
||||
<div v-if="loading" class="surface surface--tight">Loading…</div>
|
||||
<div v-else-if="error" class="surface surface--tight error">{{ error }}</div>
|
||||
<template v-else>
|
||||
<div v-if="!info?.authenticated" class="surface surface--tight">
|
||||
<p>You must be authenticated.</p>
|
||||
</div>
|
||||
<div v-else-if="!(info?.is_global_admin || info?.is_org_admin)" class="surface surface--tight">
|
||||
<p>Insufficient permissions.</p>
|
||||
</div>
|
||||
<div v-else class="admin-panels">
|
||||
<AdminOverview
|
||||
v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)"
|
||||
:info="info"
|
||||
:orgs="orgs"
|
||||
:permissions="permissions"
|
||||
:permission-summary="permissionSummary"
|
||||
@create-org="createOrg"
|
||||
@open-org="openOrg"
|
||||
@update-org="updateOrg"
|
||||
@delete-org="deleteOrg"
|
||||
@toggle-org-permission="toggleOrgPermission"
|
||||
@open-dialog="openDialog"
|
||||
@delete-permission="deletePermission"
|
||||
@rename-permission-display="renamePermissionDisplay"
|
||||
/>
|
||||
<section class="section-block admin-section">
|
||||
<div class="section-body admin-section-body">
|
||||
<div v-if="loading" class="surface surface--tight">Loading…</div>
|
||||
<div v-else-if="error" class="surface surface--tight error">{{ error }}</div>
|
||||
<template v-else>
|
||||
<div v-if="!info?.authenticated" class="surface surface--tight">
|
||||
<p>You must be authenticated.</p>
|
||||
</div>
|
||||
<div v-else-if="!(info?.is_global_admin || info?.is_org_admin)" class="surface surface--tight">
|
||||
<p>Insufficient permissions.</p>
|
||||
</div>
|
||||
<div v-else class="admin-panels">
|
||||
<AdminOverview
|
||||
v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)"
|
||||
:info="info"
|
||||
:orgs="orgs"
|
||||
:permissions="permissions"
|
||||
:permission-summary="permissionSummary"
|
||||
@create-org="createOrg"
|
||||
@open-org="openOrg"
|
||||
@update-org="updateOrg"
|
||||
@delete-org="deleteOrg"
|
||||
@toggle-org-permission="toggleOrgPermission"
|
||||
@open-dialog="openDialog"
|
||||
@delete-permission="deletePermission"
|
||||
@rename-permission-display="renamePermissionDisplay"
|
||||
/>
|
||||
|
||||
<AdminUserDetail
|
||||
v-else-if="selectedUser"
|
||||
:selected-user="selectedUser"
|
||||
:user-detail="userDetail"
|
||||
:selected-org="selectedOrg"
|
||||
:loading="loading"
|
||||
:show-reg-modal="showRegModal"
|
||||
@generate-user-registration-link="generateUserRegistrationLink"
|
||||
@go-overview="goOverview"
|
||||
@open-org="openOrg"
|
||||
@on-user-name-saved="onUserNameSaved"
|
||||
@edit-user-name="editUserName"
|
||||
@close-reg-modal="showRegModal = false"
|
||||
/>
|
||||
<AdminOrgDetail
|
||||
v-else-if="selectedOrg"
|
||||
:selected-org="selectedOrg"
|
||||
:permissions="permissions"
|
||||
@update-org="updateOrg"
|
||||
@create-role="createRole"
|
||||
@update-role="updateRole"
|
||||
@delete-role="deleteRole"
|
||||
@create-user-in-role="createUserInRole"
|
||||
@open-user="openUser"
|
||||
@toggle-role-permission="toggleRolePermission"
|
||||
@on-role-drag-over="onRoleDragOver"
|
||||
@on-role-drop="onRoleDrop"
|
||||
@on-user-drag-start="onUserDragStart"
|
||||
/>
|
||||
<AdminUserDetail
|
||||
v-else-if="selectedUser"
|
||||
:selected-user="selectedUser"
|
||||
:user-detail="userDetail"
|
||||
:selected-org="selectedOrg"
|
||||
:loading="loading"
|
||||
:show-reg-modal="showRegModal"
|
||||
@generate-user-registration-link="generateUserRegistrationLink"
|
||||
@go-overview="goOverview"
|
||||
@open-org="openOrg"
|
||||
@on-user-name-saved="onUserNameSaved"
|
||||
@edit-user-name="editUserName"
|
||||
@close-reg-modal="showRegModal = false"
|
||||
/>
|
||||
<AdminOrgDetail
|
||||
v-else-if="selectedOrg"
|
||||
:selected-org="selectedOrg"
|
||||
:permissions="permissions"
|
||||
@update-org="updateOrg"
|
||||
@create-role="createRole"
|
||||
@update-role="updateRole"
|
||||
@delete-role="deleteRole"
|
||||
@create-user-in-role="createUserInRole"
|
||||
@open-user="openUser"
|
||||
@toggle-role-permission="toggleRolePermission"
|
||||
@on-role-drag-over="onRoleDragOver"
|
||||
@on-role-drop="onRoleDrop"
|
||||
@on-user-drag-start="onUserDragStart"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
<AdminDialogs
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--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;
|
||||
@@ -42,7 +41,6 @@
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--color-canvas: #0f172a;
|
||||
--color-surface: #141b2f;
|
||||
--color-surface-subtle: #1b243b;
|
||||
@@ -57,13 +55,13 @@
|
||||
--color-accent-strong: #3b82f6;
|
||||
--color-accent-contrast: #0b1120;
|
||||
--color-success-text: #34d399;
|
||||
--color-success-bg: rgba(34, 197, 94, 0.12);
|
||||
--color-success-bg: #1a4d2e;
|
||||
--color-error-text: #fca5a5;
|
||||
--color-error-bg: rgba(248, 113, 113, 0.16);
|
||||
--color-error-bg: #4a1f1f;
|
||||
--color-info-text: #bae6fd;
|
||||
--color-info-bg: rgba(59, 130, 246, 0.16);
|
||||
--color-info-bg: #1e3a5f;
|
||||
--color-danger: #f87171;
|
||||
--shadow-soft: 0 0 0 rgba(0, 0, 0, 0);
|
||||
--shadow-soft: 0 0 0 #000000;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,15 +71,16 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
html {
|
||||
height: 100%;
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
background: var(--color-canvas);
|
||||
background: none;
|
||||
color: var(--color-text);
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -123,7 +122,6 @@ a:focus-visible {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
@@ -136,23 +134,22 @@ a:focus-visible {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
padding: var(--layout-padding);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.view-content {
|
||||
flex: 1;
|
||||
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-content--wide {
|
||||
.view-root--wide {
|
||||
width: min(100%, 1200px);
|
||||
}
|
||||
|
||||
.view-root--narrow {
|
||||
max-width: 540px;
|
||||
}
|
||||
|
||||
.view-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -232,8 +229,8 @@ button:focus-visible {
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
filter: opacity(0.6);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@@ -288,7 +285,7 @@ input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
|
||||
box-shadow: 0 0 0 3px #c7d2fe;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -372,19 +369,19 @@ th {
|
||||
}
|
||||
|
||||
.status.info {
|
||||
border-color: rgba(14, 96, 155, 0.28);
|
||||
border-color: #3b82f6;
|
||||
color: var(--color-info-text);
|
||||
background: var(--color-info-bg);
|
||||
}
|
||||
|
||||
.status.success {
|
||||
border-color: rgba(6, 118, 71, 0.22);
|
||||
border-color: #16a34a;
|
||||
color: var(--color-success-text);
|
||||
background: var(--color-success-bg);
|
||||
}
|
||||
|
||||
.status.error {
|
||||
border-color: rgba(180, 35, 24, 0.28);
|
||||
border-color: #dc2626;
|
||||
color: var(--color-error-text);
|
||||
background: var(--color-error-bg);
|
||||
}
|
||||
@@ -392,7 +389,7 @@ th {
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(9, 14, 24, 0.55);
|
||||
background: #1e293b;
|
||||
backdrop-filter: blur(6px);
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
@@ -568,8 +565,8 @@ th {
|
||||
}
|
||||
|
||||
.btn-card-delete { background: transparent; border: none; color: var(--color-danger); padding: 0.35rem 0.5rem; font-size: 1.05rem; line-height: 1; border-radius: var(--radius-sm); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.btn-card-delete:hover:not(:disabled) { background: rgba(220, 38, 38, 0.08); }
|
||||
.btn-card-delete:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-card-delete:hover:not(:disabled) { background: #fee; }
|
||||
.btn-card-delete:disabled { filter: opacity(0.4); cursor: not-allowed; }
|
||||
|
||||
|
||||
.session-emoji {
|
||||
@@ -653,9 +650,6 @@ th {
|
||||
@media (max-width: 720px) {
|
||||
.view-root {
|
||||
padding: clamp(1rem, 3vw + 0.75rem, 2rem);
|
||||
}
|
||||
|
||||
.view-content {
|
||||
gap: 1.75rem;
|
||||
}
|
||||
|
||||
@@ -683,7 +677,7 @@ th {
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
background: #334155;
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -704,7 +698,7 @@ th {
|
||||
padding: 2rem;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 20px 60px #1e293b;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -2,62 +2,60 @@
|
||||
<div class="app-shell">
|
||||
<StatusMessage />
|
||||
<main class="view-root host-view">
|
||||
<div class="view-content">
|
||||
<header class="view-header">
|
||||
<h1>{{ headingTitle }}</h1>
|
||||
<p class="view-lede">{{ subheading }}</p>
|
||||
</header>
|
||||
<header class="view-header">
|
||||
<h1>{{ headingTitle }}</h1>
|
||||
<p class="view-lede">{{ subheading }}</p>
|
||||
</header>
|
||||
|
||||
<section class="section-block">
|
||||
<div class="section-body">
|
||||
<UserBasicInfo
|
||||
v-if="user"
|
||||
:name="user.user_name"
|
||||
:visits="user.visits || 0"
|
||||
:created-at="user.created_at"
|
||||
:last-seen="user.last_seen"
|
||||
:org-display-name="orgDisplayName"
|
||||
:role-name="roleDisplayName"
|
||||
:can-edit="false"
|
||||
/>
|
||||
<p v-else class="empty-state">
|
||||
{{ initializing ? 'Loading your account…' : 'No active session found.' }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section-block">
|
||||
<div class="section-body">
|
||||
<UserBasicInfo
|
||||
v-if="user"
|
||||
:name="user.user_name"
|
||||
:visits="user.visits || 0"
|
||||
:created-at="user.created_at"
|
||||
:last-seen="user.last_seen"
|
||||
:org-display-name="orgDisplayName"
|
||||
:role-name="roleDisplayName"
|
||||
:can-edit="false"
|
||||
/>
|
||||
<p v-else class="empty-state">
|
||||
{{ initializing ? 'Loading your account…' : 'No active session found.' }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-block">
|
||||
<div class="section-body host-actions">
|
||||
<div class="button-row">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
@click="history.back()"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
:disabled="authStore.isLoading"
|
||||
@click="logout"
|
||||
>
|
||||
{{ authStore.isLoading ? 'Signing out…' : 'Logout' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="authSiteUrl"
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
:disabled="authStore.isLoading"
|
||||
@click="goToAuthSite"
|
||||
>
|
||||
Full Profile
|
||||
</button>
|
||||
</div>
|
||||
<p class="note"><strong>Logout</strong> from {{ currentHost }}, or access your <strong>Full Profile</strong> at {{ authSiteHost }} (you may need to sign in again).</p>
|
||||
<section class="section-block">
|
||||
<div class="section-body host-actions">
|
||||
<div class="button-row">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
@click="history.back()"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
:disabled="authStore.isLoading"
|
||||
@click="logout"
|
||||
>
|
||||
{{ authStore.isLoading ? 'Signing out…' : 'Logout' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="authSiteUrl"
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
:disabled="authStore.isLoading"
|
||||
@click="goToAuthSite"
|
||||
>
|
||||
Full Profile
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<p class="note"><strong>Logout</strong> from {{ currentHost }}, or access your <strong>Full Profile</strong> at {{ authSiteHost }} (you may need to sign in again).</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -7,52 +7,50 @@
|
||||
</div>
|
||||
|
||||
<main class="view-root">
|
||||
<div class="view-content">
|
||||
<div class="surface surface--tight" style="max-width: 560px; margin: 0 auto; width: 100%;">
|
||||
<header class="view-header" style="text-align: center;">
|
||||
<h1>🔑 Registration</h1>
|
||||
<p class="view-lede">
|
||||
{{ subtitleMessage }}
|
||||
</p>
|
||||
</header>
|
||||
<div class="surface surface--tight" style="max-width: 560px; margin: 0 auto; width: 100%;">
|
||||
<header class="view-header" style="text-align: center;">
|
||||
<h1>🔑 Registration</h1>
|
||||
<p class="view-lede">
|
||||
{{ subtitleMessage }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="section-block" v-if="initializing">
|
||||
<div class="section-body center">
|
||||
<p>Loading reset details…</p>
|
||||
<section class="section-block" v-if="initializing">
|
||||
<div class="section-body center">
|
||||
<p>Loading reset details…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-block" v-else>
|
||||
<div class="section-body">
|
||||
<label class="name-edit">
|
||||
<span>👤 Name</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="displayName"
|
||||
:disabled="loading"
|
||||
maxlength="64"
|
||||
@keyup.enter="registerPasskey"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
class="btn-primary"
|
||||
<section class="section-block" v-else>
|
||||
<div class="section-body">
|
||||
<label class="name-edit">
|
||||
<span>👤 Name</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="displayName"
|
||||
:disabled="loading"
|
||||
@click="registerPasskey"
|
||||
>
|
||||
{{ loading ? 'Registering…' : 'Register Passkey' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
maxlength="64"
|
||||
@keyup.enter="registerPasskey"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
class="btn-primary"
|
||||
:disabled="loading"
|
||||
@click="registerPasskey"
|
||||
>
|
||||
{{ loading ? 'Registering…' : 'Register Passkey' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<RestrictedAuth
|
||||
:mode="authMode"
|
||||
@authenticated="handleAuthenticated"
|
||||
@forbidden="handleForbidden"
|
||||
@logout="handleLogout"
|
||||
@auth-error="handleAuthError"
|
||||
>
|
||||
<template #actions="{ loading, canAuthenticate, isAuthenticated, authenticate, logout, mode }">
|
||||
<button v-if="canAuthenticate" class="btn-primary" :disabled="loading" @click="authenticate">
|
||||
{{ loading ? (mode === 'reauth' ? 'Verifying…' : 'Signing in…') : (mode === 'reauth' ? 'Verify' : 'Login') }}
|
||||
</button>
|
||||
<button v-if="isAuthenticated && mode !== 'reauth'" class="btn-danger" :disabled="loading" @click="logout">Logout</button>
|
||||
<button class="btn-secondary" :disabled="loading" @click="handleCancel">Cancel</button>
|
||||
</template>
|
||||
</RestrictedAuth>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import RestrictedAuth from '@/components/RestrictedAuth.vue'
|
||||
|
||||
// Detect mode from URL parameters or postMessage
|
||||
const authMode = computed(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('mode') === 'reauth' ? 'reauth' : 'login'
|
||||
})
|
||||
|
||||
// postMessage communication with parent window
|
||||
function postToParent(message) {
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage(message, '*')
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthenticated(result) {
|
||||
// Notify parent that authentication was successful
|
||||
postToParent({
|
||||
type: 'auth-success',
|
||||
authenticated: true,
|
||||
sessionToken: result.session_token
|
||||
})
|
||||
}
|
||||
|
||||
function handleForbidden(userInfo) {
|
||||
// Notify parent that user is authenticated but lacks permissions
|
||||
postToParent({
|
||||
type: 'auth-forbidden',
|
||||
authenticated: true,
|
||||
userInfo
|
||||
})
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
// Notify parent that logout occurred
|
||||
postToParent({
|
||||
type: 'auth-logout'
|
||||
})
|
||||
}
|
||||
|
||||
function handleAuthError({ message, cancelled }) {
|
||||
// Notify parent that authentication failed or was cancelled
|
||||
postToParent({
|
||||
type: 'auth-error',
|
||||
message: message || 'Authentication failed',
|
||||
cancelled
|
||||
})
|
||||
|
||||
// If it was a cancellation, attempt to close
|
||||
if (cancelled) {
|
||||
tryClose()
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
console.log('[RestrictedApiApp] Cancel clicked')
|
||||
// Notify parent that the operation was cancelled/incomplete
|
||||
postToParent({
|
||||
type: 'auth-cancelled',
|
||||
message: 'Authentication cancelled'
|
||||
})
|
||||
|
||||
// Attempt to close the iframe
|
||||
tryClose()
|
||||
}
|
||||
|
||||
function tryClose() {
|
||||
console.log('[RestrictedApiApp] tryClose called')
|
||||
// Signal to parent that we'd like to be removed
|
||||
// Parent can listen for this and remove the iframe element
|
||||
postToParent({
|
||||
type: 'auth-close-request'
|
||||
})
|
||||
|
||||
// Try to close (doesn't work for iframes but harmless to try)
|
||||
try {
|
||||
window.close()
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Notify parent that the iframe is ready
|
||||
postToParent({
|
||||
type: 'auth-ready'
|
||||
})
|
||||
|
||||
// Listen for messages from parent
|
||||
window.addEventListener('message', (event) => {
|
||||
// In production, you should validate event.origin
|
||||
if (event.data?.type === 'auth-check') {
|
||||
// Parent is requesting current auth status - could add this functionality
|
||||
// by exposing more state from RestrictedAuth component
|
||||
}
|
||||
})
|
||||
|
||||
// Handle Escape key to cancel
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
handleCancel()
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import RestrictedApiApp from './RestrictedApiApp.vue'
|
||||
import '@/assets/style.css'
|
||||
|
||||
createApp(RestrictedApiApp).mount('#app')
|
||||
@@ -1,137 +1,32 @@
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<div v-if="status.show" class="global-status" style="display: block;">
|
||||
<div :class="['status', status.type]">
|
||||
{{ status.message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="view-root">
|
||||
<div class="view-content">
|
||||
<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">
|
||||
<button class="btn-secondary" :disabled="loading" @click="backNav">Back</button>
|
||||
<button v-if="canAuthenticate" class="btn-primary" :disabled="loading" @click="authenticateUser">
|
||||
{{ loading ? 'Signing in…' : 'Login' }}
|
||||
</button>
|
||||
<button v-if="isAuthenticated" class="btn-danger" :disabled="loading" @click="logoutUser">Logout</button>
|
||||
<button v-if="isAuthenticated" class="btn-primary" :disabled="loading" @click="returnHome">Profile</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<RestrictedAuth
|
||||
:mode="authMode"
|
||||
@authenticated="handleAuthenticated"
|
||||
@logout="handleLogout"
|
||||
@back="backNav"
|
||||
@home="returnHome"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import passkey from '@/utils/passkey'
|
||||
import { getSettings, uiBasePath } from '@/utils/settings'
|
||||
import { computed } from 'vue'
|
||||
import RestrictedAuth from '@/components/RestrictedAuth.vue'
|
||||
import { uiBasePath } from '@/utils/settings'
|
||||
|
||||
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(() => !initializing.value && !isAuthenticated.value)
|
||||
const basePath = computed(() => uiBasePath())
|
||||
|
||||
const headingTitle = computed(() => {
|
||||
if (!isAuthenticated.value) return `🔐 ${settings.value?.rp_name || location.origin}`
|
||||
return '🚫 Forbidden'
|
||||
// Detect mode from URL parameters
|
||||
const authMode = computed(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('mode') === 'reauth' ? 'reauth' : 'login'
|
||||
})
|
||||
|
||||
const headerMessage = computed(() => {
|
||||
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) document.title = isAuthenticated.value ? `${data.rp_name} · Forbidden` : `${data.rp_name} · Sign In`
|
||||
} catch (error) {
|
||||
console.warn('Unable to load settings', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUserInfo() {
|
||||
try {
|
||||
const res = await fetch('/auth/api/user-info', { method: 'POST' })
|
||||
console.log("fetchUserInfo response:", res); // Debug log
|
||||
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()
|
||||
// If the user is authenticated but still here, they lack permissions.
|
||||
if (isAuthenticated.value) showMessage('Permission Denied', 'error', 2000)
|
||||
} 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)
|
||||
return
|
||||
}
|
||||
try { await setSessionCookie(result.session_token) } catch (error) {
|
||||
loading.value = false
|
||||
const message = error?.message || 'Failed to establish session'
|
||||
showMessage(message, 'error', 4000)
|
||||
return
|
||||
}
|
||||
function handleAuthenticated() {
|
||||
location.reload()
|
||||
}
|
||||
|
||||
async function logoutUser() {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try { await fetch('/auth/api/logout', { method: 'POST' }) } catch (_) { /* ignore */ }
|
||||
finally { loading.value = false; window.location.reload() }
|
||||
}
|
||||
|
||||
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
|
||||
function handleLogout() {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
function returnHome() {
|
||||
@@ -149,28 +44,4 @@ function backNav() {
|
||||
} catch (_) { /* ignore */ }
|
||||
returnHome()
|
||||
}
|
||||
|
||||
async function safeParseJson(response) { try { return await response.json() } catch (_) { return null } }
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchSettings()
|
||||
await fetchUserInfo()
|
||||
initializing.value = false
|
||||
})
|
||||
</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; display: flex; align-items: center; justify-content: center; padding: 2rem 1rem; }
|
||||
main.view-root .view-content { width: 100%; }
|
||||
.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