Massive style redesign, WIP.
This commit is contained in:
@@ -29,14 +29,10 @@ const crumbs = computed(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.breadcrumbs { margin: .25rem 0 .5rem; line-height:1.2; }
|
||||
.breadcrumbs ol { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; align-items: center; }
|
||||
.breadcrumbs li { display: inline-flex; align-items: center; }
|
||||
.breadcrumbs a { text-decoration: none; color: #0366d6; padding: 0 .15rem; border-radius:4px; }
|
||||
.breadcrumbs a:hover, .breadcrumbs a:focus { text-decoration: underline; }
|
||||
.breadcrumbs .sep { color: #888; margin: 0 .1rem; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.breadcrumbs a { color: #4ea3ff; }
|
||||
.breadcrumbs .sep { color: #aaa; }
|
||||
}
|
||||
.breadcrumbs { margin: .25rem 0 .5rem; line-height:1.2; color: var(--color-text-muted); }
|
||||
.breadcrumbs ol { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; align-items: center; gap: .25rem; }
|
||||
.breadcrumbs li { display: inline-flex; align-items: center; gap: .25rem; font-size: .9rem; }
|
||||
.breadcrumbs a { text-decoration: none; color: var(--color-link); padding: 0 .25rem; border-radius:4px; transition: color 0.2s ease, background 0.2s ease; }
|
||||
.breadcrumbs a:hover, .breadcrumbs a:focus-visible { text-decoration: underline; color: var(--color-link-hover); outline: none; }
|
||||
.breadcrumbs .sep { color: var(--color-text-muted); margin: 0; }
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="credential-list">
|
||||
<div v-if="loading"><p>Loading credentials...</p></div>
|
||||
<div v-else-if="!credentials?.length"><p>No passkeys found.</p></div>
|
||||
<div v-else>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="credential in credentials"
|
||||
:key="credential.credential_uuid"
|
||||
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -69,16 +69,119 @@ const getCredentialAuthIcon = (credential) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.credential-list { display: flex; flex-direction: column; gap: .75rem; margin-top: .5rem; }
|
||||
.credential-item { border: 1px solid #ddd; border-radius: 8px; padding: .5rem .75rem; background: #fff; }
|
||||
.credential-header { display: flex; align-items: center; gap: 1rem; }
|
||||
.credential-icon { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; }
|
||||
.auth-icon { border-radius: 6px; }
|
||||
.credential-info { flex: 1 1 auto; }
|
||||
.credential-info h4 { margin: 0; font-size: .9rem; }
|
||||
.credential-dates { display: grid; grid-auto-flow: column; gap: .4rem; font-size: .65rem; align-items: center; }
|
||||
.date-label { font-weight: 600; }
|
||||
.credential-actions { margin-left: auto; }
|
||||
.btn-delete-credential { background: none; border: none; cursor: pointer; font-size: .9rem; }
|
||||
.btn-delete-credential:disabled { opacity: .3; cursor: not-allowed; }
|
||||
.credential-list {
|
||||
width: 100%;
|
||||
margin-top: var(--space-sm);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1rem 1.25rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.credential-item {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.85rem 1rem;
|
||||
background: var(--color-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
width: 28rem;
|
||||
height: 100%;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.credential-item:hover {
|
||||
border-color: var(--color-border-strong);
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.credential-item.current-session {
|
||||
border-color: var(--color-accent);
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
}
|
||||
|
||||
.credential-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.credential-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--color-surface-subtle, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.auth-icon {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.credential-info {
|
||||
flex: 1 1 150px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.credential-info h4 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
.credential-dates {
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.35rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
align-items: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.date-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.date-value {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.credential-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-delete-credential {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
color: var(--color-danger);
|
||||
padding: 0.25rem 0.35rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.btn-delete-credential:hover:not(:disabled) {
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
}
|
||||
|
||||
.btn-delete-credential:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.credential-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,39 +1,48 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="view active">
|
||||
<h1>📱 Add Another Device</h1>
|
||||
<div class="device-link-section">
|
||||
<div class="qr-container">
|
||||
<a :href="url" id="deviceLinkText" @click="copyLink">
|
||||
<canvas id="qrCode" class="qr-code"></canvas>
|
||||
<p v-if="url">
|
||||
{{ url.replace(/^[^:]+:\/\//, '') }}
|
||||
</p>
|
||||
<p v-else>
|
||||
<em>Generating link...</em>
|
||||
</p>
|
||||
</a>
|
||||
<p>
|
||||
<strong>Scan and visit the URL on another device.</strong><br>
|
||||
<small>⚠️ Expires in 24 hours and can only be used once.</small>
|
||||
</p>
|
||||
<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>
|
||||
<section class="section-block">
|
||||
<div class="section-body">
|
||||
<div class="device-link-section">
|
||||
<div class="qr-container">
|
||||
<a :href="url" class="qr-link" @click="copyLink">
|
||||
<canvas ref="qrCanvas" class="qr-code"></canvas>
|
||||
<p v-if="url">
|
||||
{{ url.replace(/^[^:]+:\/\//, '') }}
|
||||
</p>
|
||||
<p v-else>
|
||||
<em>Generating link...</em>
|
||||
</p>
|
||||
</a>
|
||||
<p>
|
||||
<strong>Scan and visit the URL on another device.</strong><br>
|
||||
<small>⚠️ Expires in 24 hours and can only be used once.</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button @click="authStore.currentView = 'profile'" class="btn-secondary">
|
||||
Back to Profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="authStore.currentView = 'profile'" class="btn-secondary">
|
||||
Back to Profile
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import QRCode from 'qrcode/lib/browser'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const url = ref(null)
|
||||
const qrCanvas = ref(null)
|
||||
|
||||
const copyLink = async (event) => {
|
||||
event.preventDefault()
|
||||
@@ -44,24 +53,56 @@ const copyLink = async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
async function drawQr() {
|
||||
if (!url.value || !qrCanvas.value) return
|
||||
await nextTick()
|
||||
QRCode.toCanvas(qrCanvas.value, url.value, { scale: 8 }, (error) => {
|
||||
if (error) console.error('Failed to generate QR code:', error)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await fetch('/auth/api/create-link', { method: 'POST' })
|
||||
const response = await fetch('/auth/api/create-link', { method: 'POST' })
|
||||
const result = await response.json()
|
||||
if (result.detail) throw new Error(result.detail)
|
||||
|
||||
url.value = result.url
|
||||
|
||||
// Generate QR code
|
||||
const qrCodeElement = document.getElementById('qrCode')
|
||||
if (qrCodeElement) {
|
||||
QRCode.toCanvas(qrCodeElement, url.value, {scale: 8 }, error => {
|
||||
if (error) console.error('Failed to generate QR code:', error)
|
||||
})
|
||||
}
|
||||
await drawQr()
|
||||
} catch (error) {
|
||||
authStore.showMessage(`Failed to create device link: ${error.message}`, 'error')
|
||||
authStore.currentView = 'profile'
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-content--narrow {
|
||||
max-width: 540px;
|
||||
}
|
||||
|
||||
.view-lede {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.qr-link {
|
||||
text-decoration: none;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.button-row {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.button-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.button-row button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,34 +1,36 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="view active">
|
||||
<h1>🔐 {{ (authStore.settings?.rp_name || 'Passkey') + ' Login' }}</h1>
|
||||
<form @submit.prevent="handleLogin">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
:disabled="authStore.isLoading"
|
||||
>
|
||||
{{ authStore.isLoading ? 'Authenticating...' : 'Login with Your Device' }}
|
||||
</button>
|
||||
</form>
|
||||
<section class="view-root view-login">
|
||||
<div class="view-content view-content--narrow">
|
||||
<header class="view-header">
|
||||
<h1>🔐 {{ (authStore.settings?.rp_name || 'Passkey') + ' Login' }}</h1>
|
||||
<p class="view-lede">Sign in securely with a device you trust.</p>
|
||||
</header>
|
||||
<section class="section-block">
|
||||
<form class="section-body" @submit.prevent="handleLogin">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
:disabled="authStore.isLoading"
|
||||
>
|
||||
{{ authStore.isLoading ? 'Authenticating...' : 'Login with Your Device' }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
console.log('Login button clicked')
|
||||
authStore.showMessage('Starting authentication...', 'info')
|
||||
await authStore.authenticate()
|
||||
authStore.showMessage('Authentication successful!', 'success', 2000)
|
||||
if (authStore.restrictedMode) {
|
||||
// Restricted mode: reload so the app re-mounts and selectView() applies (will become permission denied)
|
||||
location.reload()
|
||||
} else if (location.pathname === '/auth/') {
|
||||
authStore.currentView = 'profile'
|
||||
@@ -40,3 +42,24 @@ const handleLogin = async () => {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-content--narrow {
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.view-lede {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.view-login .section-body {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="view active">
|
||||
<h1>🚫 Forbidden</h1>
|
||||
<div v-if="authStore.userInfo?.authenticated" class="user-header">
|
||||
<span class="user-emoji" aria-hidden="true">{{ userEmoji }}</span>
|
||||
<span class="user-name">{{ displayName }}</span>
|
||||
</div>
|
||||
<p>You lack the permissions required for this page.</p>
|
||||
<div class="actions">
|
||||
<button class="btn-secondary" @click="back">Back</button>
|
||||
<button class="btn-primary" @click="goAuth">Account</button>
|
||||
<button class="btn-danger" @click="logout">Logout</button>
|
||||
</div>
|
||||
<section class="view-root view-denied">
|
||||
<div class="view-content view-content--narrow">
|
||||
<header class="view-header">
|
||||
<h1>🚫 Forbidden</h1>
|
||||
<p class="view-lede">You don’t have the permissions required to view this page.</p>
|
||||
</header>
|
||||
<section class="section-block">
|
||||
<div class="section-body">
|
||||
<div v-if="authStore.userInfo?.authenticated" class="user-header">
|
||||
<span class="user-emoji" aria-hidden="true">{{ userEmoji }}</span>
|
||||
<span class="user-name">{{ displayName }}</span>
|
||||
</div>
|
||||
<p>You lack the permissions required for this page.</p>
|
||||
<div class="button-row">
|
||||
<button class="btn-secondary" @click="back">Back</button>
|
||||
<button class="btn-primary" @click="goAuth">Account</button>
|
||||
<button class="btn-danger" @click="logout">Logout</button>
|
||||
</div>
|
||||
<p class="hint">If you believe this is an error, contact your administrator.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<script setup>
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -35,9 +43,55 @@ async function logout() {
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.user-header { display:flex; align-items:center; gap:.5rem; font-size:1.1rem; margin-bottom:.75rem; }
|
||||
.user-emoji { font-size:1.5rem; line-height:1; }
|
||||
.user-name { font-weight:600; }
|
||||
.actions { margin-top:1.5rem; display:flex; gap:.5rem; flex-wrap:nowrap; }
|
||||
.hint { font-size:.9rem; opacity:.85; }
|
||||
.view-content--narrow {
|
||||
max-width: 540px;
|
||||
}
|
||||
|
||||
.view-lede {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.user-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.user-emoji {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
.button-row {
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.button-row button {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.button-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.button-row button {
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,91 +1,67 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="view active">
|
||||
<h1>👋 Welcome!</h1>
|
||||
<Breadcrumbs :entries="[{ label: 'Auth', href: '/auth/' }, ...(isAdmin ? [{ label: 'Admin', href: '/auth/admin/' }] : [])]" />
|
||||
<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()"
|
||||
/>
|
||||
<section class="view-root" data-view="profile">
|
||||
<div class="view-content">
|
||||
<header class="view-header">
|
||||
<h1>👋 Welcome!</h1>
|
||||
<Breadcrumbs :entries="[{ label: 'Auth', href: '/auth/' }, ...(isAdmin ? [{ label: 'Admin', href: '/auth/admin/' }] : [])]" />
|
||||
<p class="view-lede">Manage your account details and passkeys.</p>
|
||||
</header>
|
||||
|
||||
<h2>Your Passkeys</h2>
|
||||
<div class="credential-list">
|
||||
<div v-if="authStore.isLoading">
|
||||
<p>Loading credentials...</p>
|
||||
<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()"
|
||||
/>
|
||||
</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 v-else-if="authStore.userInfo?.credentials?.length === 0">
|
||||
<p>No passkeys found.</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-for="credential in authStore.userInfo?.credentials || []"
|
||||
:key="credential.credential_uuid"
|
||||
:class="['credential-item', { 'current-session': credential.is_current_session }]"
|
||||
>
|
||||
<div class="credential-header">
|
||||
<div class="credential-icon">
|
||||
<img
|
||||
v-if="getCredentialAuthIcon(credential)"
|
||||
:src="getCredentialAuthIcon(credential)"
|
||||
:alt="getCredentialAuthName(credential)"
|
||||
class="auth-icon"
|
||||
width="32"
|
||||
height="32"
|
||||
>
|
||||
<span v-else class="auth-emoji">🔑</span>
|
||||
</div>
|
||||
<div class="credential-info">
|
||||
<h4>{{ getCredentialAuthName(credential) }}</h4>
|
||||
</div>
|
||||
<div class="credential-dates">
|
||||
<span class="date-label">Created:</span>
|
||||
<span class="date-value">{{ formatDate(credential.created_at) }}</span>
|
||||
<span class="date-label">Last used:</span>
|
||||
<span class="date-value">{{ formatDate(credential.last_used) }}</span>
|
||||
</div>
|
||||
<div class="credential-actions">
|
||||
<button
|
||||
@click="deleteCredential(credential.credential_uuid)"
|
||||
class="btn-delete-credential"
|
||||
:disabled="credential.is_current_session"
|
||||
:title="credential.is_current_session ? 'Cannot delete current session credential' : ''"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</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="authStore.currentView = 'device-link'" class="btn-secondary">
|
||||
Add Another Device
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="button-group" style="display: flex; gap: 10px;">
|
||||
<button @click="addNewCredential" class="btn-primary">
|
||||
Add New Passkey
|
||||
</button>
|
||||
<button @click="authStore.currentView = 'device-link'" class="btn-primary">
|
||||
Add Another Device
|
||||
</button>
|
||||
</div>
|
||||
<button @click="logout" class="btn-danger" style="width: 100%;">
|
||||
Logout
|
||||
</button>
|
||||
<section class="section-block">
|
||||
<div class="button-row">
|
||||
<button @click="logout" class="btn-danger logout-button">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import Breadcrumbs from '@/components/Breadcrumbs.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
import passkey from '@/utils/passkey'
|
||||
import CredentialList from '@/components/CredentialList.vue'
|
||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import passkey from '@/utils/passkey'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const updateInterval = ref(null)
|
||||
@@ -105,20 +81,6 @@ onUnmounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const getCredentialAuthName = (credential) => {
|
||||
const authInfo = authStore.userInfo?.aaguid_info?.[credential.aaguid]
|
||||
return authInfo ? authInfo.name : 'Unknown Authenticator'
|
||||
}
|
||||
|
||||
const getCredentialAuthIcon = (credential) => {
|
||||
const authInfo = authStore.userInfo?.aaguid_info?.[credential.aaguid]
|
||||
if (!authInfo) return null
|
||||
|
||||
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
const iconKey = isDarkMode ? 'icon_dark' : 'icon_light'
|
||||
return authInfo[iconKey] || null
|
||||
}
|
||||
|
||||
const addNewCredential = async () => {
|
||||
try {
|
||||
authStore.isLoading = true
|
||||
@@ -134,7 +96,9 @@ const addNewCredential = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const deleteCredential = async (credentialId) => {
|
||||
const handleDelete = async (credential) => {
|
||||
const credentialId = credential?.credential_uuid
|
||||
if (!credentialId) return
|
||||
if (!confirm('Are you sure you want to delete this passkey?')) return
|
||||
try {
|
||||
await authStore.deleteCredential(credentialId)
|
||||
@@ -152,19 +116,31 @@ const isAdmin = computed(() => !!(authStore.userInfo?.is_global_admin || authSto
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Removed inline user info styles; now provided by UserBasicInfo component */
|
||||
.admin-link {
|
||||
font-size: 0.6em;
|
||||
margin-left: 0.75rem;
|
||||
text-decoration: none;
|
||||
background: var(--color-background-soft, #eee);
|
||||
padding: 0.2em 0.6em;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border, #ccc);
|
||||
vertical-align: middle;
|
||||
line-height: 1.2;
|
||||
.view-lede {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1rem;
|
||||
}
|
||||
.admin-link:hover {
|
||||
background: var(--color-background-mute, #ddd);
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.logout-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,28 +1,37 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="view active">
|
||||
<h1>🔑 Add New Credential</h1>
|
||||
<label class="name-edit">
|
||||
<span>👤 Name:</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="user_name"
|
||||
:placeholder="authStore.userInfo?.user?.user_name || 'Your name'"
|
||||
:disabled="authStore.isLoading"
|
||||
maxlength="64"
|
||||
@keyup.enter="register"
|
||||
/>
|
||||
</label>
|
||||
<p>Proceed to complete {{authStore.userInfo?.session_type}}:</p>
|
||||
<button
|
||||
class="btn-primary"
|
||||
:disabled="authStore.isLoading"
|
||||
@click="register"
|
||||
>
|
||||
{{ authStore.isLoading ? 'Registering...' : 'Register Passkey' }}
|
||||
</button>
|
||||
<section class="view-root view-reset">
|
||||
<div class="view-content view-content--narrow">
|
||||
<header class="view-header">
|
||||
<h1>🔑 Add New Credential</h1>
|
||||
<p class="view-lede">
|
||||
Finish setting up your passkey to complete {{ authStore.userInfo?.session_type }}.
|
||||
</p>
|
||||
</header>
|
||||
<section class="section-block">
|
||||
<div class="section-body">
|
||||
<label class="name-edit">
|
||||
<span>👤 Name</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="user_name"
|
||||
:placeholder="authStore.userInfo?.user?.user_name || 'Your name'"
|
||||
:disabled="authStore.isLoading"
|
||||
maxlength="64"
|
||||
@keyup.enter="register"
|
||||
/>
|
||||
</label>
|
||||
<p>Proceed to complete {{ authStore.userInfo?.session_type }}:</p>
|
||||
<button
|
||||
class="btn-primary"
|
||||
:disabled="authStore.isLoading"
|
||||
@click="register"
|
||||
>
|
||||
{{ authStore.isLoading ? 'Registering…' : 'Register Passkey' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -31,21 +40,20 @@ import passkey from '@/utils/passkey'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const user_name = ref('') // intentionally blank; original shown via placeholder
|
||||
const user_name = ref('')
|
||||
|
||||
async function register() {
|
||||
authStore.isLoading = true
|
||||
authStore.showMessage('Starting registration...', 'info')
|
||||
|
||||
try {
|
||||
const result = await passkey.register(authStore.resetToken, user_name.value)
|
||||
console.log("Result", result)
|
||||
await authStore.setSessionCookie(result.session_token)
|
||||
// resetToken cleared by setSessionCookie; ensure again
|
||||
authStore.resetToken = null
|
||||
authStore.showMessage('Passkey registered successfully!', 'success', 2000)
|
||||
await authStore.loadUserInfo()
|
||||
authStore.selectView()
|
||||
const result = await passkey.register(authStore.resetToken, user_name.value)
|
||||
console.log('Result', result)
|
||||
await authStore.setSessionCookie(result.session_token)
|
||||
authStore.resetToken = null
|
||||
authStore.showMessage('Passkey registered successfully!', 'success', 2000)
|
||||
await authStore.loadUserInfo()
|
||||
authStore.selectView()
|
||||
} catch (error) {
|
||||
authStore.showMessage(`Registration failed: ${error.message}`, 'error')
|
||||
} finally {
|
||||
@@ -53,3 +61,36 @@ async function register() {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-content--narrow {
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.view-lede {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.name-edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.name-edit span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.section-body {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -83,19 +83,19 @@ watch(() => props.name, () => { if (!props.name) editingName.value = false })
|
||||
.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; }
|
||||
.org-line { font-size: .7rem; font-weight:600; line-height:1.1; }
|
||||
.role-line { font-size:.6rem; color:#555; line-height:1.1; }
|
||||
.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; }
|
||||
.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; }
|
||||
.display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.name-input { width: auto; flex: 1 1 140px; min-width: 120px; padding: 6px 8px; font-size: 0.9em; border: 1px solid #a9c5d6; border-radius: 6px; }
|
||||
.name-input { width: auto; flex: 1 1 140px; min-width: 120px; padding: 6px 8px; font-size: 0.9em; border: 1px solid var(--color-border-strong); border-radius: 6px; background: var(--color-surface); color: var(--color-text); }
|
||||
.user-name-heading .name-input { width: auto; }
|
||||
.name-input:focus { outline: 2px solid #667eea55; border-color: #667eea; }
|
||||
.mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; background: #eef5fa; border: 1px solid #b7d2e3; border-radius: 6px; cursor: pointer; transition: background 0.2s, transform 0.15s; }
|
||||
.mini-btn:hover:not(:disabled) { background: #dcecf6; }
|
||||
.name-input:focus { outline: none; border-color: var(--color-accent); box-shadow: var(--focus-ring); }
|
||||
.mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; background: var(--color-surface-muted); border: 1px solid var(--color-border-strong); border-radius: 6px; cursor: pointer; transition: background 0.2s, transform 0.15s, color 0.2s ease; color: var(--color-text); }
|
||||
.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: 480px) { .user-name-heading { flex-direction: column; align-items: flex-start; } .user-name-row.editing { width: 100%; } .display-name { max-width: 100%; } }
|
||||
|
||||
Reference in New Issue
Block a user