Implement restricted-api for JS-driven auth calls, examples added (WIP!). Layout and styling simplified.
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PassKey Auth - Examples</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🔐 PassKey Auth Examples</h1>
|
||||
<p class="subtitle">Interactive demos and code examples for PassKey authentication</p>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<div class="examples-grid">
|
||||
<a href="/examples/restricted-api.html" class="example-card">
|
||||
<h3 class="example-title">API Demo: login and re-authentication via JS.</h3>
|
||||
<p class="example-description">
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,242 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Restricted API Demo</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🔐 Restricted API Demo</h1>
|
||||
<p class="subtitle">Demonstrates iframe-based authentication using postMessage</p>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<div class="section">
|
||||
<h2>Authentication Modes</h2>
|
||||
<button onclick="showLoginMode()">Show Login Mode</button>
|
||||
<button onclick="showReauthMode()">Show Reauth Mode</button>
|
||||
<button onclick="hideIframe()">Hide Iframe</button>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>API Tests</h2>
|
||||
<button onclick="testUserInfo()">Get User Info</button>
|
||||
<button onclick="testProtectedAPI()">Test Protected API</button>
|
||||
<button onclick="logout()">Logout</button>
|
||||
</div>
|
||||
|
||||
<div id="status" class="hidden"></div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Message Events</h2>
|
||||
<pre id="message-log">Waiting for messages...</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
let currentApiCall = null;
|
||||
let iframeInitialized = false;
|
||||
const messageLog = document.getElementById('message-log');
|
||||
let messages = [];
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const data = event.data;
|
||||
if (!data?.type) return;
|
||||
|
||||
// Log all messages
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
messages.push(`[${timestamp}] ${JSON.stringify(data, null, 2)}`);
|
||||
if (messages.length > 10) messages.shift();
|
||||
messageLog.textContent = messages.join('\n\n');
|
||||
|
||||
switch (data.type) {
|
||||
case 'auth-ready':
|
||||
iframeInitialized = true;
|
||||
showStatus('Authentication iframe is ready', 'info');
|
||||
break;
|
||||
|
||||
case 'auth-success':
|
||||
showStatus('✓ Authentication successful!', 'success');
|
||||
hideAuthIframe();
|
||||
if (currentApiCall) {
|
||||
setTimeout(() => {
|
||||
currentApiCall();
|
||||
currentApiCall = null;
|
||||
}, 500);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auth-forbidden':
|
||||
showStatus('⚠ You are authenticated but lack required permissions', 'error');
|
||||
hideAuthIframe();
|
||||
currentApiCall = null;
|
||||
break;
|
||||
|
||||
case 'auth-logout':
|
||||
showStatus('User logged out', 'info');
|
||||
currentApiCall = null;
|
||||
break;
|
||||
|
||||
case 'auth-error':
|
||||
showStatus(`⚠ ${data.message || 'Authentication failed'}`, data.cancelled ? 'info' : 'error');
|
||||
if (data.cancelled) {
|
||||
hideAuthIframe();
|
||||
currentApiCall = null;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auth-cancelled':
|
||||
showStatus(`Operation cancelled: ${data.message || ''}`, 'info');
|
||||
hideAuthIframe();
|
||||
currentApiCall = null;
|
||||
break;
|
||||
|
||||
case 'auth-close-request':
|
||||
// Iframe wants to be closed
|
||||
hideAuthIframe();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
function showLoginMode() {
|
||||
let iframe = document.getElementById('auth-iframe');
|
||||
if (!iframe) {
|
||||
iframe = document.createElement('iframe');
|
||||
iframe.id = 'auth-iframe';
|
||||
iframe.title = 'Authentication';
|
||||
document.body.appendChild(iframe);
|
||||
}
|
||||
iframe.src = '/auth/restricted-api/?mode=login';
|
||||
showStatus('Login mode loaded - for users who are not authenticated', 'info');
|
||||
}
|
||||
|
||||
function showReauthMode() {
|
||||
let iframe = document.getElementById('auth-iframe');
|
||||
if (!iframe) {
|
||||
iframe = document.createElement('iframe');
|
||||
iframe.id = 'auth-iframe';
|
||||
iframe.title = 'Authentication';
|
||||
document.body.appendChild(iframe);
|
||||
}
|
||||
iframe.src = '/auth/restricted-api/?mode=reauth';
|
||||
showStatus('Reauth mode loaded - for additional verification of authenticated users', 'info');
|
||||
}
|
||||
|
||||
function showAuthIframe() {
|
||||
let iframe = document.getElementById('auth-iframe');
|
||||
if (!iframe) {
|
||||
iframe = document.createElement('iframe');
|
||||
iframe.id = 'auth-iframe';
|
||||
iframe.title = 'Authentication';
|
||||
iframe.src = '/auth/restricted-api';
|
||||
document.body.appendChild(iframe);
|
||||
iframeInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
function hideIframe() {
|
||||
hideAuthIframe();
|
||||
showStatus('Iframe hidden', 'info');
|
||||
}
|
||||
|
||||
function hideAuthIframe() {
|
||||
const iframe = document.getElementById('auth-iframe');
|
||||
if (iframe) iframe.remove();
|
||||
}
|
||||
|
||||
async function testUserInfo() {
|
||||
showStatus('Fetching user info...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/auth/api/user-info', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
showStatus(`⚠ Authentication required (${response.status})`);
|
||||
currentApiCall = testUserInfo;
|
||||
showAuthIframe();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
showStatus(`Error: ${response.status} ${response.statusText}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
showStatus('✓ User info retrieved successfully!');
|
||||
displayApiResponse('User Info', data);
|
||||
} catch (error) {
|
||||
showStatus(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function testProtectedAPI() {
|
||||
showStatus('Testing protected API...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/auth/api/user-info', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
showStatus(`⚠ Authentication required (${response.status})`);
|
||||
currentApiCall = testProtectedAPI;
|
||||
showAuthIframe();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
showStatus(`Error: ${response.status} ${response.statusText}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
showStatus('✓ Protected API call successful!');
|
||||
displayApiResponse('Protected Data', data);
|
||||
} catch (error) {
|
||||
showStatus(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
showStatus('Logging out...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/auth/api/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok || response.status === 401) {
|
||||
showStatus('✓ Logged out successfully!');
|
||||
hideAuthIframe();
|
||||
currentApiCall = null;
|
||||
} else {
|
||||
showStatus(`Logout error: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
showStatus(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function showStatus(message, type = 'info') {
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.innerHTML = `<p>${message}</p>`;
|
||||
statusDiv.className = `status ${type}`;
|
||||
statusDiv.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function displayApiResponse(title, data) {
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.innerHTML += `<h3>${title}</h3><pre>${JSON.stringify(data, null, 2)}</pre>`;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body:has(iframe) {
|
||||
overflow: hidden; /* prevent scrolling the page */
|
||||
}
|
||||
|
||||
iframe {
|
||||
/* Fullscreen overlay */
|
||||
border: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 9999;
|
||||
/* Transparent background with a backdrop effect (optional) */
|
||||
color-scheme: auto;
|
||||
backdrop-filter: blur(4px) brightness(0.7);
|
||||
-webkit-backdrop-filter: blur(4px) brightness(0.7);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<html style="background: transparent"><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><div id="app"></div><script type="module" src="/src/restricted-api/main.js"></script>
|
||||
@@ -484,8 +484,7 @@ 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">
|
||||
<section class="view-root view-root--wide view-admin">
|
||||
<header class="view-header">
|
||||
<h1>{{ pageHeading }}</h1>
|
||||
<Breadcrumbs :entries="breadcrumbEntries" />
|
||||
@@ -553,7 +552,6 @@ async function submitDialog() {
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</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,6 +1,5 @@
|
||||
<template>
|
||||
<section class="view-root view-device-link">
|
||||
<div class="view-content view-content--narrow">
|
||||
<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>
|
||||
@@ -17,7 +16,6 @@
|
||||
<div class="button-row" style="margin-top:1rem;">
|
||||
<button @click="authStore.currentView = 'profile'" class="btn-secondary">Back to Profile</button>
|
||||
</div>
|
||||
</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) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<section class="view-root" data-view="profile">
|
||||
<div class="view-content">
|
||||
<header class="view-header">
|
||||
<h1>👋 Welcome!</h1>
|
||||
<Breadcrumbs :entries="breadcrumbEntries" />
|
||||
@@ -86,7 +85,6 @@
|
||||
@close="showRegLink = false"
|
||||
@copied="showRegLink = false; authStore.showMessage('Link copied to clipboard!', 'success', 2500)"
|
||||
/>
|
||||
</div>
|
||||
</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,7 +2,6 @@
|
||||
<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>
|
||||
@@ -57,7 +56,6 @@
|
||||
<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>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
</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>
|
||||
@@ -53,7 +52,6 @@
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+34
-30
@@ -1,20 +1,35 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import { resolve } from 'node:path'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { readFileSync, existsSync, statSync } from 'node:fs'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(({ command, mode }) => ({
|
||||
export default defineConfig(({ command }) => ({
|
||||
appType: 'mpa',
|
||||
plugins: [
|
||||
vue(),
|
||||
{
|
||||
name: 'serve-examples',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
const url = req.url?.split('?')[0]
|
||||
if (url === '/examples') return res.writeHead(301, { Location: '/examples/' }).end()
|
||||
if (url?.startsWith('/examples/')) {
|
||||
const file = resolve(__dirname, '../examples', url === '/examples/' ? 'index.html' : url.slice(10))
|
||||
if (existsSync(file) && statSync(file).isFile()) {
|
||||
res.setHeader('Content-Type', { '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript' }[file.slice(file.lastIndexOf('.'))] || 'text/plain')
|
||||
return res.end(readFileSync(file))
|
||||
}
|
||||
return res.writeHead(404).end()
|
||||
}
|
||||
next()
|
||||
})
|
||||
}
|
||||
}
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }
|
||||
},
|
||||
},
|
||||
// Use absolute paths at dev, deploy under /auth/
|
||||
base: command === 'build' ? '/auth/' : '/',
|
||||
server: {
|
||||
port: 4403,
|
||||
@@ -22,26 +37,16 @@ export default defineConfig(({ command, mode }) => ({
|
||||
'/auth/': {
|
||||
target: 'http://localhost:4402',
|
||||
ws: true,
|
||||
changeOrigin: false,
|
||||
// We proxy API + WS under /auth/, but want Vite to serve the SPA entrypoints
|
||||
// and static assets so that HMR works. Bypass tells http-proxy to skip
|
||||
// proxying when we return a (possibly rewritten) local path.
|
||||
bypass(req) {
|
||||
const rawUrl = req.url || ''
|
||||
// Strip query/hash to match path-only for SPA entrypoints with query params (e.g. ?reset=token)
|
||||
const url = rawUrl.split('?')[0].split('#')[0]
|
||||
// Bypass only root SPA entrypoints + static assets so Vite serves them for HMR.
|
||||
// Admin API endpoints (e.g., /auth/admin/orgs) must still hit backend.
|
||||
if (url === '/auth/' || url === '/auth') return '/'
|
||||
if (url === '/auth/host' || url === '/auth/host/') return '/host/index.html'
|
||||
if (url === '/host' || url === '/host/') return '/host/index.html'
|
||||
if (url === '/auth/admin' || url === '/auth/admin/') return '/admin/'
|
||||
if (url.startsWith('/auth/assets/')) return url.replace(/^\/auth/, '')
|
||||
if (/^\/auth\/([a-z]+\.){4}[a-z]+\/?$/.test(url)) return '/reset/index.html'
|
||||
if (/^\/([a-z]+\.){4}[a-z]+\/?$/.test(url)) return '/reset/index.html'
|
||||
if (url === '/auth/restricted' || url === '/auth/restricted/') return '/restricted/index.html'
|
||||
if (url === '/restricted' || url === '/restricted/') return '/restricted/index.html'
|
||||
// Everything else (including /auth/admin/* APIs) should proxy.
|
||||
bypass: (req) => {
|
||||
const url = req.url?.split('?')[0]
|
||||
if (url?.startsWith('/auth/assets/')) return url.slice(5)
|
||||
|
||||
const routes = { '': '/', host: '/host/index.html', admin: '/admin/', restricted: '/restricted/index.html', 'restricted-api': '/restricted-api/index.html' }
|
||||
for (const [path, target] of Object.entries(routes)) {
|
||||
if ([`/auth/${path}`, `/auth/${path}/`, `/${path}`, `/${path}/`].includes(url)) return target
|
||||
}
|
||||
|
||||
if (/^\/auth\/([a-z]+\.){4}[a-z]+\/?$|^\/([a-z]+\.){4}[a-z]+\/?$/.test(url)) return '/reset/index.html'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,16 +54,15 @@ export default defineConfig(({ command, mode }) => ({
|
||||
build: {
|
||||
outDir: '../passkey/frontend-build',
|
||||
emptyOutDir: true,
|
||||
assetsDir: 'assets',
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'index.html'),
|
||||
admin: resolve(__dirname, 'admin/index.html'),
|
||||
reset: resolve(__dirname, 'reset/index.html'),
|
||||
restricted: resolve(__dirname, 'restricted/index.html'),
|
||||
'restricted-api': resolve(__dirname, 'restricted-api/index.html'),
|
||||
host: resolve(__dirname, 'host/index.html')
|
||||
},
|
||||
output: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -101,6 +101,17 @@ async def admin_root(request: Request, auth=AUTH_COOKIE):
|
||||
return await admin.adminapp(request, auth) # Delegated (enforces access control)
|
||||
|
||||
|
||||
@app.get("/auth/restricted")
|
||||
async def restricted_view():
|
||||
return FileResponse(frontend.file("restricted", "index.html"))
|
||||
|
||||
|
||||
@app.get("/auth/restricted-api")
|
||||
async def restricted_api_view():
|
||||
return FileResponse(frontend.file("restricted-api", "index.html"))
|
||||
|
||||
|
||||
# Note: this catch-all handler must be the last route defined
|
||||
@app.get("/{reset}")
|
||||
@app.get("/auth/{reset}")
|
||||
async def reset_link(reset: str):
|
||||
@@ -108,9 +119,3 @@ async def reset_link(reset: str):
|
||||
if not passphrase.is_well_formed(reset):
|
||||
raise HTTPException(status_code=404)
|
||||
return FileResponse(frontend.file("reset", "index.html"))
|
||||
|
||||
|
||||
@app.get("/restricted", include_in_schema=False)
|
||||
@app.get("/auth/restricted", include_in_schema=False)
|
||||
async def restricted_view():
|
||||
return FileResponse(frontend.file("restricted", "index.html"))
|
||||
|
||||
@@ -27,7 +27,7 @@ BUN_BUG = """\
|
||||
|
||||
NO_FRONTEND = """\
|
||||
┃
|
||||
┃ Note: only static build of the frontend is served at port 8078.
|
||||
┃ Note: only static build of the frontend is served at localhost:4402.
|
||||
┃ The page will not update with frontend code changes.
|
||||
"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user