---
import { ViewTransitions } from 'astro:transitions';
export interface Props {
title: string;
description?: string;
requireAuth?: boolean;
}
const { title, description = "Service Collection Management Dashboard", requireAuth = true } = Astro.props;
// Get auth context from middleware
const { auth, currentProjectId } = Astro.locals;
const user = auth?.user;
const isAuthenticated = auth?.isAuthenticated || false;
// Fetch user's projects if authenticated
let projects = [];
let currentProject = null;
if (isAuthenticated && user) {
try {
const projectsResponse = await fetch('http://vultr-backend:8000/api/projects/', {
headers: {
'Authorization': `Bearer ${Astro.cookies.get('auth_token')?.value}`,
'Content-Type': 'application/json'
}
});
if (projectsResponse.ok) {
projects = await projectsResponse.json();
// Set current project (prefer from cookie, fallback to first project)
if (currentProjectId) {
currentProject = projects.find((p: any) => p.id === currentProjectId);
}
if (!currentProject && projects.length > 0) {
currentProject = projects[0];
}
}
} catch (error) {
console.error('Failed to fetch projects:', error);
}
}
// Get current pathname for navigation active state
const currentPath = Astro.url.pathname;
// Helper function to determine if a navigation item is active
function getNavClasses(path: string): string {
const isActive = currentPath === path || (path !== '/' && currentPath.startsWith(path));
return isActive
? "border-primary-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium";
}
// Helper function for mobile navigation classes
function getMobileNavClasses(path: string): string {
const isActive = currentPath === path || (path !== '/' && currentPath.startsWith(path));
return isActive
? "bg-primary-50 border-primary-500 text-primary-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium"
: "border-transparent text-gray-600 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-800 block pl-3 pr-4 py-2 border-l-4 text-base font-medium";
}
---
<!doctype html>
<html lang="en" class="h-full bg-gray-50">
<head>
<meta charset="UTF-8" />
<meta name="description" content={description} />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
<ViewTransitions />
<!-- Use system fonts for faster loading -->
<!-- Global styles -->
<style is:global>
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
body {
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
}
.font-mono {
font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Segoe UI Mono', 'Roboto Mono', 'Ubuntu Mono', monospace;
}
}
@layer components {
.btn-primary {
@apply inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500;
}
.btn-secondary {
@apply inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500;
}
.form-input {
@apply appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm;
}
.form-select {
@apply block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md;
}
}
</style>
</head>
<body class="h-full">
{/* Auth is now handled server-side by middleware */}
<!-- Main Application Container -->
<div class="min-h-full">
<!-- Navigation -->
<nav class="bg-white shadow-sm border-b border-gray-200 relative z-50" x-data="{ mobileMenuOpen: false }" transition:name="main-nav">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<!-- Left side - Logo and main nav -->
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<a href="/dashboard" class="flex items-center space-x-3">
<img src="https://avatars.githubusercontent.com/u/8527264?s=200&v=4"
alt="Vultr"
class="h-8 w-8 rounded">
<span class="text-xl font-bold text-gray-900">Service Collections</span>
</a>
</div>
<!-- Project Selector -->
<div class="hidden sm:ml-6 sm:flex sm:items-center" x-data="dropdown()">
<div class="relative">
<button type="button"
class="bg-white border border-gray-200 rounded-md px-4 py-2 inline-flex items-center text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
@click="toggle()"
x-bind:aria-expanded="isOpen">
<div class="flex items-center space-x-2">
{currentProject && (
<div class="h-2 w-2 rounded-full" style={`background-color: ${currentProject.color || '#6B7280'}`}></div>
)}
<span>{currentProject?.name || 'No Project'}</span>
</div>
<svg class="ml-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
<!-- Backdrop overlay for better coverage -->
<div x-show="isOpen"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-black bg-opacity-10 z-[100]"
@click="close()">
</div>
<div x-show="isOpen"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="origin-top-left absolute left-0 mt-2 w-72 rounded-md shadow-lg bg-white ring-1 ring-gray-900 ring-opacity-5 focus:outline-none z-[110]"
style="background-color: #ffffff;"
@click.away="close()">
<div class="py-1">
<div class="px-4 py-2 text-xs font-semibold text-gray-900 uppercase tracking-wide border-b border-gray-100">
Switch Project
</div>
<div class="max-h-64 overflow-y-auto">
{projects.map((project: any) => (
<button type="button"
class={`w-full text-left px-4 py-3 text-sm text-gray-700 hover:bg-gray-100 flex items-center space-x-3 ${currentProject?.id === project.id ? 'bg-primary-50' : ''}`}
onclick={`window.setCurrentProject('${project.id}'); document.querySelector('[x-data*="dropdown"]').dispatchEvent(new CustomEvent('close'));`}>
<div class="h-2 w-2 rounded-full flex-shrink-0" style={`background-color: ${project.color || '#6B7280'}`}></div>
<div class="flex-1 min-w-0">
<div class="font-medium">{project.name}</div>
<div class="text-xs text-gray-500 truncate">{project.description}</div>
</div>
<div class="flex-shrink-0">
<span class={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
project.status === 'active' ? 'bg-green-100 text-green-800' :
project.status === 'suspended' ? 'bg-yellow-100 text-yellow-800' :
'bg-gray-100 text-gray-800'
}`}>
{project.status}
</span>
</div>
</button>
))}
</div>
<div class="border-t border-gray-100">
<a href="/projects?create=true"
class="w-full text-left px-4 py-3 text-sm text-primary-600 hover:bg-primary-50 font-medium flex items-center space-x-2">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span>Create Project</span>
</a>
<a href="/projects"
class="block px-4 py-3 text-sm text-gray-700 hover:bg-gray-100 font-medium">
Manage Projects
</a>
</div>
</div>
</div>
</div>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/dashboard" class={getNavClasses("/dashboard")}>
Dashboard
</a>
<a href="/projects" class={getNavClasses("/projects")}>
Projects
</a>
<a href="/collections" class={getNavClasses("/collections")}>
Collections
</a>
<a href="/credentials" class={getNavClasses("/credentials")}>
Credentials
</a>
<a href="/resources" class={getNavClasses("/resources")}>
Resources
</a>
<a href="/workflows" class={getNavClasses("/workflows")}>
Workflows
</a>
</div>
</div>
<!-- Right side - User menu -->
<div class="hidden sm:ml-6 sm:flex sm:items-center">
<!-- Notifications -->
<button type="button" class="bg-white p-1 rounded-full text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
<span class="sr-only">View notifications</span>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
</svg>
</button>
<!-- Profile dropdown -->
<div class="ml-3 relative" x-data="dropdown()">
<div>
<button type="button"
class="bg-white flex text-sm rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
@click="toggle()"
x-bind:aria-expanded="isOpen">
<span class="sr-only">Open user menu</span>
{user?.avatar_url ? (
<div class="h-8 w-8 rounded-full overflow-hidden">
<img src={user.avatar_url}
alt={`${user.full_name}`}
class="h-8 w-8 object-cover">
</div>
) : (
<div class="h-8 w-8 rounded-full bg-primary-100 flex items-center justify-center">
<span class="text-sm font-medium text-primary-600">{user?.full_name?.[0] || 'U'}</span>
</div>
)}
</button>
</div>
<div x-show="isOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-50"
@click.away="close()">
<div class="px-4 py-2 text-sm text-gray-700 border-b border-gray-100">
<div class="font-medium">{user?.full_name}</div>
<div class="text-gray-500">{user?.email}</div>
</div>
<a href="/profile" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Profile</a>
<a href="/settings" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Settings</a>
<form method="post" action="/api/auth/logout" class="block">
<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Sign out</button>
</form>
</div>
</div>
</div>
<!-- Mobile menu button -->
<div class="-mr-2 flex items-center sm:hidden">
<button type="button"
class="bg-white inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500"
@click="mobileMenuOpen = !mobileMenuOpen">
<span class="sr-only">Open main menu</span>
<svg class="h-6 w-6" x-show="!mobileMenuOpen" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
<svg class="h-6 w-6" x-show="mobileMenuOpen" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<!-- Mobile menu -->
<div class="sm:hidden" x-show="mobileMenuOpen">
<div class="pt-2 pb-3 space-y-1">
<a href="/dashboard" class={getMobileNavClasses("/dashboard")}>Dashboard</a>
<a href="/projects" class={getMobileNavClasses("/projects")}>Projects</a>
<a href="/collections" class={getMobileNavClasses("/collections")}>Collections</a>
<a href="/credentials" class={getMobileNavClasses("/credentials")}>Credentials</a>
<a href="/resources" class={getMobileNavClasses("/resources")}>Resources</a>
<a href="/workflows" class={getMobileNavClasses("/workflows")}>Workflows</a>
</div>
<div class="pt-4 pb-3 border-t border-gray-200">
<div class="flex items-center px-4">
<div class="flex-shrink-0">
{user?.avatar_url ? (
<div class="h-10 w-10 rounded-full overflow-hidden">
<img src={user.avatar_url}
alt={`${user.full_name}`}
class="h-10 w-10 object-cover">
</div>
) : (
<div class="h-10 w-10 rounded-full bg-primary-100 flex items-center justify-center">
<span class="text-sm font-medium text-primary-600">{user?.full_name?.[0] || 'U'}</span>
</div>
)}
</div>
<div class="ml-3">
<div class="text-base font-medium text-gray-800">{user?.full_name}</div>
<div class="text-sm font-medium text-gray-500">{user?.email}</div>
</div>
</div>
<div class="mt-3 space-y-1">
<a href="/profile" class="block px-4 py-2 text-base font-medium text-gray-500 hover:text-gray-800 hover:bg-gray-100">Profile</a>
<a href="/settings" class="block px-4 py-2 text-base font-medium text-gray-500 hover:text-gray-800 hover:bg-gray-100">Settings</a>
<form method="post" action="/api/auth/logout" class="block">
<button type="submit" class="block w-full text-left px-4 py-2 text-base font-medium text-gray-500 hover:text-gray-800 hover:bg-gray-100">Sign out</button>
</form>
</div>
</div>
</div>
</nav>
<!-- Page content -->
<main class="flex-1" transition:name="main-content">
<slot />
</main>
</div>
<!-- Global notification system -->
<div x-data="notification()"
x-show="visible"
x-transition:enter="transform ease-out duration-300 transition"
x-transition:enter-start="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
x-transition:enter-end="translate-y-0 opacity-100 sm:translate-x-0"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 flex items-end justify-center px-4 py-6 pointer-events-none sm:p-6 sm:items-start sm:justify-end z-50">
<div class="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden">
<div class="p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<svg x-show="type === 'success'" class="h-6 w-6 text-green-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg x-show="type === 'error'" class="h-6 w-6 text-red-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
<svg x-show="type === 'warning'" class="h-6 w-6 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
<svg x-show="type === 'info'" class="h-6 w-6 text-blue-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
</div>
<div class="ml-3 w-0 flex-1 pt-0.5">
<p class="text-sm font-medium text-gray-900" x-text="message"></p>
</div>
<div class="ml-4 flex-shrink-0 flex">
<button class="bg-white rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500" @click="hide()">
<span class="sr-only">Close</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Token refresh and API utilities (must load before other scripts) -->
<script is:inline>
// Global API fetch wrapper with automatic token refresh
// This provides a seamless experience when access tokens expire
// State for coordinating refresh attempts
let isRefreshing = false;
let refreshPromise = null;
/**
* Attempt to refresh the access token using the refresh token cookie
* Returns true if refresh succeeded, false otherwise
*/
async function refreshAccessToken() {
// If already refreshing, wait for that to complete
if (isRefreshing && refreshPromise) {
return refreshPromise;
}
isRefreshing = true;
refreshPromise = (async () => {
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({})
});
if (response.ok) {
console.log('[API] Token refresh successful');
return true;
}
console.warn('[API] Token refresh failed:', response.status);
return false;
} catch (error) {
console.error('[API] Token refresh error:', error);
return false;
} finally {
isRefreshing = false;
refreshPromise = null;
}
})();
return refreshPromise;
}
/**
* Redirect to login page
*/
function redirectToLogin(preserveReturnUrl = true) {
const currentPath = window.location.pathname + window.location.search;
const loginUrl = preserveReturnUrl && currentPath !== '/login'
? `/login?redirect=${encodeURIComponent(currentPath)}`
: '/login';
if (document.startViewTransition) {
document.startViewTransition(() => { window.location.href = loginUrl; });
} else {
window.location.href = loginUrl;
}
}
/**
* Global fetch wrapper with automatic token refresh
*
* Usage:
* const response = await apiFetch('/api/collections');
* const data = await response.json();
*
* Features:
* - Automatically includes credentials (cookies)
* - Detects 401 responses and attempts token refresh
* - Retries original request after successful refresh
* - Redirects to login on unrecoverable auth failure
*/
window.apiFetch = async function(url, options = {}, retryOnAuthFailure = true) {
const fetchOptions = {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options.headers
}
};
const response = await fetch(url, fetchOptions);
// If not a 401, return the response as-is
if (response.status !== 401) {
return response;
}
// Don't retry refresh endpoint to avoid infinite loops
if (url.includes('/api/auth/refresh') || !retryOnAuthFailure) {
redirectToLogin();
throw new Error('Authentication required');
}
console.log('[API] Got 401, attempting token refresh...');
// Attempt to refresh the token
const refreshed = await refreshAccessToken();
if (refreshed) {
// Retry the original request
console.log('[API] Retrying original request after token refresh');
return fetch(url, fetchOptions);
}
// Refresh failed - redirect to login
redirectToLogin();
throw new Error('Session expired. Please log in again.');
};
// Convenience methods
window.apiGet = (url) => window.apiFetch(url, { method: 'GET' });
window.apiPost = (url, data) => window.apiFetch(url, { method: 'POST', body: data ? JSON.stringify(data) : undefined });
window.apiPut = (url, data) => window.apiFetch(url, { method: 'PUT', body: data ? JSON.stringify(data) : undefined });
window.apiPatch = (url, data) => window.apiFetch(url, { method: 'PATCH', body: data ? JSON.stringify(data) : undefined });
window.apiDelete = (url) => window.apiFetch(url, { method: 'DELETE' });
/**
* Logout the current user (clears all tokens)
*/
window.logout = async function() {
try {
await window.apiFetch('/api/auth/logout', { method: 'POST' }, false);
} catch (error) {
console.warn('[API] Logout request failed:', error);
}
redirectToLogin(false);
};
/**
* Logout from all devices
*/
window.revokeAllSessions = async function() {
try {
const response = await window.apiFetch('/api/auth/revoke-all-tokens', { method: 'POST' });
return response.ok;
} catch (error) {
console.error('[API] Failed to revoke all sessions:', error);
return false;
}
};
</script>
<!-- Project switching and utilities -->
<script type="module" define:vars={{ serverProject: currentProject, serverProjects: projects }}>
// Import PassKey utilities first
import { confirmWithPassKey, authenticateWithPassKey, hasPassKeys } from '/src/js/passkey-utils.js';
// Import project store
import { currentProject } from '/src/stores/projectStore';
// Utility function to get authentication headers (legacy support)
// New code should use window.apiFetch() instead
function getAuthHeaders() {
const token = document.cookie
.split('; ')
.find(row => row.startsWith('auth_token='))
?.split('=')[1];
return {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : ''
};
}
// Make getAuthHeaders globally available for backward compatibility
window.getAuthHeaders = getAuthHeaders;
// Initialize project store from server-side data
if (serverProject) {
currentProject.set(serverProject);
}
// Global function to switch projects (using nanostore)
window.setCurrentProject = async function(projectId) {
try {
// Find the full project data from available projects
const project = serverProjects.find((p) => p.id === projectId);
if (project) {
// Update nanostore (automatically syncs to localStorage)
currentProject.set(project);
// Reload the page to get server-side updates
window.location.reload();
}
} catch (error) {
console.error('Failed to set current project:', error);
}
};
// Wait for Alpine to be available and register stores immediately
function waitForAlpine() {
return new Promise((resolve) => {
if (window.Alpine) {
resolve();
} else {
const checkAlpine = setInterval(() => {
if (window.Alpine) {
clearInterval(checkAlpine);
resolve();
}
}, 10);
}
});
}
// Auth Guard Component
window.authGuard = function() {
return {
async checkAuth() {
console.log('π Starting auth check...');
// Wait for auth store to be fully initialized
while (!window.Alpine?.store || !Alpine.store('auth') || Alpine.store('auth').authConfig === null) {
await new Promise(resolve => setTimeout(resolve, 50));
}
console.log('π Running auth check after initialization...');
const isAuth = await Alpine.store('auth').checkAuth();
console.log('π Auth check result:', isAuth);
if (!isAuth) {
console.log('β Not authenticated, redirecting to login');
window.location.href = '/login';
}
}
};
};
// Global utility functions for UI components
window.notification = function() {
return {
visible: false,
type: 'info',
message: '',
show(type, message, duration = 5000) {
this.type = type;
this.message = message;
this.visible = true;
setTimeout(() => {
this.hide();
}, duration);
},
hide() {
this.visible = false;
}
};
};
window.modal = function() {
return {
isOpen: false,
open() {
this.isOpen = true;
document.body.style.overflow = 'hidden';
},
close() {
this.isOpen = false;
document.body.style.overflow = 'auto';
}
};
};
window.dropdown = function() {
return {
isOpen: false,
toggle() {
this.isOpen = !this.isOpen;
},
close() {
this.isOpen = false;
}
};
};
// PassKey confirmation dialog component
window.passkeyConfirmDialog = function(operationContext) {
return {
open: true,
loading: false,
error: null,
async confirm() {
this.loading = true;
this.error = null;
try {
const result = await authenticateWithPassKey(operationContext);
if (result) {
// Close dialog and resolve with success
this.close(true);
} else {
this.error = 'Authentication failed. Please try again.';
}
} catch (error) {
console.error('PassKey authentication error:', error);
this.error = error.message || 'Authentication failed. Please try again.';
}
this.loading = false;
},
close(confirmed = false) {
this.open = false;
// Remove modal from DOM after animation
setTimeout(() => {
const modal = document.getElementById(`passkey-confirm-${Date.now()}`);
if (modal && modal.parentNode) {
modal.parentNode.removeChild(modal);
}
}, 300);
// Resolve the promise from confirmWithPassKey
if (window.passkeyConfirmResolve) {
window.passkeyConfirmResolve(confirmed);
window.passkeyConfirmResolve = null;
}
}
};
};
// Initialize Alpine.js components after stores are ready
document.addEventListener('alpine:init', async () => {
console.log('π― Alpine init event - initializing components...');
// We now use server-side authentication via middleware
// Alpine.js stores are only used for UI interactions (dropdowns, modals, notifications)
// No need to initialize auth or projects stores since data comes from server-side
console.log('β
Alpine.js initialization complete (server-side auth mode)');
});
</script>
<!-- Alpine.js is loaded via Astro integration (@astrojs/alpinejs) -->
</body>
</html>
<style>
html {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.font-mono {
font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Segoe UI Mono', 'Roboto Mono', 'Ubuntu Mono', monospace;
}
</style>