Files
cairnobs/web/src/lib/components/NavSidebar.svelte
T
jcoffey-dev dd5d5a77c3 Show the account controls the deployment actually has
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]>
2026-09-04 17:42:26 -07:00

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>