The sidebar decided which auth mode was live from enterpriseAuthBase, so any deployment with VITE_ENTERPRISE_AUTH_BASE_URL set rendered the enterprise block -- and compose sets it unconditionally, so the tenant picker can exist. On a single-tenant stack with local login on, that meant the local block could never render: no username, no role, no Change password, no Log out, and in their place a "Sign in" link pointing at enterprise-auth's OIDC route, which is disabled unless OIDC_ISSUER_URL is configured. A dead link where the account controls should be. The build-time flag was never the right thing to ask. api registers /auth/* only when LOCAL_AUTH_ENABLED is set and ENTERPRISE_AUTH_URL is not, so the frontend cannot know the mode from its own build args -- the two can disagree, and here they did. getLocalSession already distinguishes 'disabled' (a 404 from /auth/session) from null (a 401, logged out); the sidebar collapsed both to null and threw the answer away. It now keeps that distinction and branches on it, so the mode comes from what the server actually serves. Logged out under local auth, the sidebar previously rendered no auth block at all -- no way back to the login page from the nav. It now offers Sign in, pointing at /login. Neither block renders until the probe lands, so nothing flashes the wrong mode on load. Signed-off-by: John Coffey <[email protected]>
405 lines
11 KiB
Svelte
405 lines
11 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/state';
|
|
import {
|
|
getCurrentSession,
|
|
enterpriseAuthBase,
|
|
localAuthEnabled,
|
|
getLocalSession,
|
|
logout,
|
|
type CurrentSession,
|
|
type LocalSession
|
|
} from '$lib/api';
|
|
import { getTheme, setTheme, isLight, type Theme } from '$lib/theme.svelte';
|
|
import { getDensity, toggleDensity } from '$lib/density.svelte';
|
|
import logoDark from '$lib/assets/logo-horizontal-dark.svg';
|
|
import logoLight from '$lib/assets/logo-horizontal-light.svg';
|
|
|
|
let {
|
|
onOpenPalette,
|
|
mobileOpen = false,
|
|
onCloseMobile
|
|
}: { onOpenPalette: () => void; mobileOpen?: boolean; onCloseMobile?: () => void } = $props();
|
|
|
|
const baseNavItems = [
|
|
{ href: '/search', label: 'Search', icon: '◇' },
|
|
{ href: '/dashboards', label: 'Dashboards', icon: '▤' },
|
|
{ href: '/alerts', label: 'Alerts', icon: '▲' },
|
|
{ href: '/data-sources', label: 'Data Sources', icon: '◈' },
|
|
{ href: '/agents', label: 'Agents', icon: '●' },
|
|
{ href: '/hosts', label: 'Hosts', icon: '▣' }
|
|
];
|
|
const usersNavItem = { href: '/users', label: 'Users', icon: '◐' };
|
|
const settingsNavItem = { href: '/settings', label: 'Settings', icon: '⚙' };
|
|
|
|
function isActive(href: string): boolean {
|
|
if (href === '/') return page.url.pathname === '/';
|
|
return page.url.pathname.startsWith(href);
|
|
}
|
|
|
|
let session: CurrentSession | null = $state(null);
|
|
$effect(() => {
|
|
getCurrentSession().then((s) => (session = s));
|
|
});
|
|
|
|
let localSession: LocalSession | null = $state(null);
|
|
// Whether the server actually serves local auth, which is not the
|
|
// same question as whether this bundle was built with it enabled.
|
|
// api registers /auth/* only when LOCAL_AUTH_ENABLED is set AND
|
|
// ENTERPRISE_AUTH_URL is not (see cmd/api/main.go's authorizer
|
|
// switch), so getLocalSession's 'disabled' -- a 404 from
|
|
// /auth/session -- is the only trustworthy signal of which mode is
|
|
// live. Deciding from enterpriseAuthBase alone got this wrong:
|
|
// compose sets VITE_ENTERPRISE_AUTH_BASE_URL unconditionally so the
|
|
// tenant picker can exist, so that check was true even on a
|
|
// single-tenant deployment whose actual authenticator was local
|
|
// login, and the local block below could never render.
|
|
//
|
|
// null means "not yet known" -- neither block renders until the
|
|
// probe lands, rather than flashing the wrong one.
|
|
let localAuthAvailable: boolean | null = $state(localAuthEnabled ? null : false);
|
|
$effect(() => {
|
|
if (!localAuthEnabled) return;
|
|
getLocalSession().then((s) => {
|
|
localAuthAvailable = s !== 'disabled';
|
|
localSession = s === 'disabled' ? null : s;
|
|
});
|
|
});
|
|
|
|
// The Users nav item only ever makes sense for local-auth mode's
|
|
// owner/admin user manager (see routes/users/+page.svelte, which
|
|
// admin can now partially use too -- viewer/editor accounts only) --
|
|
// an enterprise-SSO deployment or a plain viewer/editor local
|
|
// session never sees it, same gating that page enforces itself if
|
|
// reached directly.
|
|
const canManageUsers = $derived.by(() => {
|
|
const s = localSession;
|
|
return s !== null && (s.role === 'owner' || s.role === 'admin');
|
|
});
|
|
const navItems = $derived(
|
|
canManageUsers ? [...baseNavItems, usersNavItem, settingsNavItem] : [...baseNavItems, settingsNavItem]
|
|
);
|
|
|
|
let loggingOut = $state(false);
|
|
async function handleLogout() {
|
|
loggingOut = true;
|
|
try {
|
|
await logout();
|
|
} finally {
|
|
window.location.href = '/login';
|
|
}
|
|
}
|
|
|
|
const themeOptions: { value: Theme; label: string }[] = [
|
|
{ value: 'dark', label: 'Dark' },
|
|
{ value: 'light', label: 'Light' },
|
|
{ value: 'system', label: 'System' }
|
|
];
|
|
</script>
|
|
|
|
{#if mobileOpen}
|
|
<button type="button" class="backdrop" onclick={onCloseMobile} aria-label="Close menu"></button>
|
|
{/if}
|
|
|
|
<aside class="sidebar" class:mobile-open={mobileOpen}>
|
|
<div class="brand">
|
|
<a href="/" class="brand-link" onclick={onCloseMobile}>
|
|
<img src={isLight() ? logoLight : logoDark} alt="Cairn OBS" class="brand-logo" />
|
|
</a>
|
|
<button type="button" class="close-mobile" onclick={onCloseMobile} aria-label="Close menu">✕</button>
|
|
</div>
|
|
|
|
{#if localAuthAvailable}
|
|
<div class="tenant">
|
|
{#if localSession}
|
|
<div class="tenant-pill">
|
|
<span class="dot" aria-hidden="true"></span>
|
|
<span class="tenant-name">{localSession.username}</span>
|
|
<span class="role">{localSession.role}</span>
|
|
</div>
|
|
<a class="switch" href="/account">Change password</a>
|
|
<button type="button" class="switch logout-btn" onclick={handleLogout} disabled={loggingOut}>
|
|
{loggingOut ? 'Signing out…' : 'Log out'}
|
|
</button>
|
|
{:else}
|
|
<a class="switch signin" href="/login">Sign in</a>
|
|
{/if}
|
|
</div>
|
|
{:else if localAuthAvailable === false && enterpriseAuthBase}
|
|
<div class="tenant">
|
|
{#if session}
|
|
<div class="tenant-pill">
|
|
<span class="dot" aria-hidden="true"></span>
|
|
<span class="tenant-name">{session.tenant_id}</span>
|
|
<span class="role">{session.role}</span>
|
|
</div>
|
|
<a class="switch" href="{enterpriseAuthBase}/auth/oidc/login">Switch tenant</a>
|
|
{:else}
|
|
<a class="switch signin" href="{enterpriseAuthBase}/auth/oidc/login">Sign in</a>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<nav aria-label="Main">
|
|
{#each navItems as item (item.href)}
|
|
<a
|
|
href={item.href}
|
|
class:active={isActive(item.href)}
|
|
aria-current={isActive(item.href) ? 'page' : undefined}
|
|
onclick={onCloseMobile}
|
|
>
|
|
<span class="ic" aria-hidden="true">{item.icon}</span>
|
|
{item.label}
|
|
</a>
|
|
{/each}
|
|
</nav>
|
|
|
|
<div class="footer">
|
|
<button type="button" class="palette-hint" onclick={onOpenPalette}>
|
|
<span>Jump to…</span>
|
|
<kbd>⌘K</kbd>
|
|
</button>
|
|
|
|
<div class="controls">
|
|
<label for="theme-select" class="sr-only">Theme</label>
|
|
<select id="theme-select" value={getTheme()} onchange={(e) => setTheme(e.currentTarget.value as Theme)}>
|
|
{#each themeOptions as opt (opt.value)}
|
|
<option value={opt.value}>{opt.label}</option>
|
|
{/each}
|
|
</select>
|
|
<button type="button" class="density-toggle" onclick={toggleDensity} title="Toggle row density">
|
|
{getDensity() === 'compact' ? 'Compact' : 'Comfortable'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<style>
|
|
.sidebar {
|
|
background: var(--color-surface);
|
|
border-right: 1px solid var(--color-border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-5);
|
|
padding: var(--space-4) var(--space-3);
|
|
height: 100vh;
|
|
position: sticky;
|
|
top: 0;
|
|
}
|
|
.brand {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
padding: 0 var(--space-2);
|
|
}
|
|
.brand-link {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
.brand-logo {
|
|
height: 2.5rem;
|
|
width: auto;
|
|
}
|
|
.close-mobile {
|
|
display: none;
|
|
margin-left: auto;
|
|
background: none;
|
|
border: none;
|
|
color: var(--color-text-muted);
|
|
font-size: var(--text-md);
|
|
cursor: pointer;
|
|
padding: var(--space-1);
|
|
}
|
|
.backdrop {
|
|
display: none;
|
|
}
|
|
|
|
.tenant {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-1);
|
|
}
|
|
.tenant-pill {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
padding: var(--space-2) var(--space-3);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
font-size: var(--text-sm);
|
|
}
|
|
.dot {
|
|
width: 7px;
|
|
height: 7px;
|
|
border-radius: 50%;
|
|
background: var(--color-accent);
|
|
flex: none;
|
|
}
|
|
.tenant-name {
|
|
font-weight: var(--font-weight-medium);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.role {
|
|
margin-left: auto;
|
|
color: var(--color-text-muted);
|
|
font-size: var(--text-xs);
|
|
text-transform: uppercase;
|
|
}
|
|
.switch {
|
|
font-size: var(--text-xs);
|
|
color: var(--color-text-muted);
|
|
padding: 0 var(--space-3);
|
|
text-decoration: none;
|
|
}
|
|
.switch:hover {
|
|
color: var(--color-accent);
|
|
}
|
|
.logout-btn {
|
|
background: none;
|
|
border: none;
|
|
font-family: var(--font-ui);
|
|
width: 100%;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
}
|
|
.logout-btn:disabled {
|
|
cursor: default;
|
|
opacity: 0.6;
|
|
}
|
|
.switch.signin {
|
|
display: block;
|
|
padding: var(--space-2) var(--space-3);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
font-size: var(--text-sm);
|
|
color: var(--color-text);
|
|
text-align: center;
|
|
}
|
|
|
|
nav {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-1);
|
|
}
|
|
nav a {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-3);
|
|
padding: var(--space-2) var(--space-3);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--color-text-muted);
|
|
text-decoration: none;
|
|
font-size: var(--text-base);
|
|
}
|
|
nav a:hover {
|
|
background: var(--color-surface-raised);
|
|
color: var(--color-text);
|
|
}
|
|
nav a.active {
|
|
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
|
color: var(--color-text);
|
|
font-weight: var(--font-weight-medium);
|
|
}
|
|
.ic {
|
|
width: 1rem;
|
|
text-align: center;
|
|
opacity: 0.85;
|
|
}
|
|
|
|
.footer {
|
|
margin-top: auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-2);
|
|
}
|
|
.palette-hint {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
width: 100%;
|
|
background: none;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
padding: var(--space-2) var(--space-3);
|
|
color: var(--color-text-muted);
|
|
font-family: var(--font-ui);
|
|
font-size: var(--text-sm);
|
|
cursor: pointer;
|
|
}
|
|
.palette-hint:hover {
|
|
border-color: var(--color-border-strong);
|
|
color: var(--color-text);
|
|
}
|
|
kbd {
|
|
font-family: var(--font-mono);
|
|
background: var(--color-bg);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 3px;
|
|
padding: 0.05rem 0.3rem;
|
|
font-size: var(--text-xs);
|
|
}
|
|
.controls {
|
|
display: flex;
|
|
gap: var(--space-2);
|
|
}
|
|
.controls select,
|
|
.density-toggle {
|
|
flex: 1;
|
|
height: 1.9rem;
|
|
font-size: var(--text-xs);
|
|
background: var(--color-bg);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--color-text-muted);
|
|
font-family: var(--font-ui);
|
|
cursor: pointer;
|
|
}
|
|
.sr-only {
|
|
position: absolute;
|
|
width: 1px;
|
|
height: 1px;
|
|
overflow: hidden;
|
|
clip: rect(0 0 0 0);
|
|
}
|
|
|
|
/* Below this width the sidebar becomes an off-canvas drawer
|
|
(+layout.svelte renders a menu button to open it) instead of a
|
|
fixed grid column -- "shouldn't break on a laptop screen or a
|
|
tablet in landscape" is the actual bar (this is a desktop-first
|
|
tool), so the breakpoint is deliberately narrower than a phone
|
|
viewport would need. */
|
|
@media (max-width: 860px) {
|
|
.sidebar {
|
|
position: fixed;
|
|
left: 0;
|
|
top: 0;
|
|
z-index: 90;
|
|
width: 16rem;
|
|
transform: translateX(-100%);
|
|
transition: transform 0.15s ease;
|
|
box-shadow: var(--shadow-lg);
|
|
}
|
|
.sidebar.mobile-open {
|
|
transform: translateX(0);
|
|
}
|
|
.close-mobile {
|
|
display: block;
|
|
}
|
|
.backdrop {
|
|
display: block;
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
border: none;
|
|
z-index: 80;
|
|
padding: 0;
|
|
}
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.sidebar {
|
|
transition: none;
|
|
}
|
|
}
|
|
</style>
|