1
0
forked from wrenn/wrenn

Add BYOC page, admin section, and is_byoc team visibility gating

- Frontend: BYOC hosts page (/dashboard/byoc) with register/delete flows,
  shimmer loading, pulsing online status, animated token reveal checkmark
- Frontend: Admin section (/admin/hosts) with platform + BYOC tabs, stat
  pills, skeleton loading, slide-in animations for new rows
- Frontend: AdminSidebar component with accent top bar and admin pill badge
- Frontend: BYOC nav item shown only when team.is_byoc is true (derived
  from teams store, not JWT); disabled for members
- Frontend: Admin shield button in Sidebar, visible only to platform admins
- Backend: is_admin in JWT claims + requireAdmin middleware (DB-validated)
- Backend: is_byoc added to teamResponse so frontend derives visibility
  from fresh team data rather than stale JWT fields
- Backend: SetBYOC admin endpoint (PUT /v1/admin/teams/{id}/byoc)
- Backend: Admin hosts list enriches BYOC entries with team_name
- Host agent: load .env file via godotenv on startup
This commit is contained in:
2026-03-25 03:10:41 +06:00
parent 9bf67aa7f7
commit e069b3e679
36 changed files with 2200 additions and 163 deletions

View File

@ -0,0 +1,84 @@
import { apiFetch } from './client';
export type Host = {
id: string;
type: 'regular' | 'byoc';
team_id?: string;
team_name?: string;
provider?: string;
availability_zone?: string;
arch?: string;
cpu_cores?: number;
memory_mb?: number;
disk_gb?: number;
address?: string;
status: 'pending' | 'online' | 'offline' | 'unreachable' | 'draining';
last_heartbeat_at?: string;
created_by: string;
created_at: string;
updated_at: string;
};
export type CreateHostParams = {
type: 'regular' | 'byoc';
team_id?: string;
provider?: string;
availability_zone?: string;
};
export type CreateHostResult = {
host: Host;
registration_token: string;
};
export async function listHosts(): Promise<{ ok: true; data: Host[] } | { ok: false; error: string }> {
return apiFetch<Host[]>('GET', '/api/v1/hosts');
}
export async function createHost(
params: CreateHostParams
): Promise<{ ok: true; data: CreateHostResult } | { ok: false; error: string }> {
return apiFetch<CreateHostResult>('POST', '/api/v1/hosts', params);
}
export async function deleteHost(
id: string,
force = false
): Promise<{ ok: true } | { ok: false; error: string; sandbox_ids?: string[] }> {
const url = `/api/v1/hosts/${id}${force ? '?force=true' : ''}`;
const res = await apiFetch<void>('DELETE', url);
if (!res.ok) {
return res as { ok: false; error: string };
}
return { ok: true };
}
export async function getDeletePreview(
id: string
): Promise<{ ok: true; data: { host: Host; sandbox_ids: string[] } } | { ok: false; error: string }> {
return apiFetch<{ host: Host; sandbox_ids: string[] }>('GET', `/api/v1/hosts/${id}/delete-preview`);
}
export function statusColor(status: Host['status']): string {
switch (status) {
case 'online':
return 'var(--color-accent)';
case 'pending':
return 'var(--color-amber)';
case 'offline':
case 'unreachable':
return 'var(--color-red)';
case 'draining':
return 'var(--color-blue)';
default:
return 'var(--color-text-muted)';
}
}
export function formatSpecs(host: Host): string {
const parts: string[] = [];
if (host.cpu_cores) parts.push(`${host.cpu_cores} vCPU`);
if (host.memory_mb) parts.push(`${Math.round(host.memory_mb / 1024)}GB RAM`);
if (host.disk_gb) parts.push(`${host.disk_gb}GB disk`);
return parts.join(' · ') || '—';
}

View File

@ -29,6 +29,7 @@ export type TeamWithRole = {
id: string;
name: string;
slug: string;
is_byoc: boolean;
created_at: string;
role: string;
};

View File

@ -19,12 +19,23 @@ function isTokenExpired(token: string): boolean {
}
}
function decodeJWTPayload(token: string): Record<string, unknown> {
try {
const payload = token.split('.')[1];
return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
} catch {
return {};
}
}
function createAuth() {
let token = $state<string | null>(null);
let userId = $state<string | null>(null);
let teamId = $state<string | null>(null);
let email = $state<string | null>(null);
let name = $state<string | null>(null);
let isAdmin = $state(false);
let role = $state<string>('member');
let initialized = $state(false);
// Initialize from localStorage synchronously at module load.
@ -36,6 +47,9 @@ function createAuth() {
teamId = localStorage.getItem(STORAGE_KEYS.teamId);
email = localStorage.getItem(STORAGE_KEYS.email);
name = localStorage.getItem(STORAGE_KEYS.name);
const payload = decodeJWTPayload(stored);
isAdmin = Boolean(payload.is_admin);
role = String(payload.role || 'member');
} else if (stored) {
// Expired — clean up.
for (const key of Object.values(STORAGE_KEYS)) {
@ -63,6 +77,12 @@ function createAuth() {
get name() {
return name;
},
get isAdmin() {
return isAdmin;
},
get role() {
return role;
},
get isAuthenticated() {
return isAuthenticated;
},
@ -76,6 +96,9 @@ function createAuth() {
teamId = data.team_id;
email = data.email;
name = data.name;
const payload = decodeJWTPayload(data.token);
isAdmin = Boolean(payload.is_admin);
role = String(payload.role || 'member');
localStorage.setItem(STORAGE_KEYS.token, data.token);
localStorage.setItem(STORAGE_KEYS.userId, data.user_id);
@ -90,6 +113,8 @@ function createAuth() {
teamId = null;
email = null;
name = null;
isAdmin = false;
role = 'member';
for (const key of Object.values(STORAGE_KEYS)) {
localStorage.removeItem(key);

View File

@ -0,0 +1,184 @@
<script lang="ts">
import { page } from '$app/stores';
import { auth } from '$lib/auth.svelte';
import {
IconServer,
IconSettings,
IconLogout,
IconSidebar,
IconBell,
IconDocs,
IconChevron,
IconShield
} from './icons';
let { collapsed = $bindable(false) }: { collapsed: boolean } = $props();
type NavItem = {
label: string;
icon: typeof IconServer;
href: string;
};
const managementItems: NavItem[] = [
{ label: 'Hosts', icon: IconServer, href: '/admin/hosts' }
];
function isActive(href: string): boolean {
const p = $page.url.pathname;
return p === href || p.startsWith(href + '/');
}
function toggleCollapsed() {
collapsed = !collapsed;
localStorage.setItem('wrenn_sidebar_collapsed', String(collapsed));
}
let userName = $derived(auth.name || auth.email || '');
</script>
<aside
class="relative flex h-screen shrink-0 flex-col overflow-hidden border-r border-[var(--color-border)] bg-[var(--color-bg-1)] transition-[width] duration-250 ease-in-out"
style="width: {collapsed ? '56px' : '230px'}"
>
<!-- Subtle accent top-edge — marks this as an elevated context -->
<div class="absolute inset-x-0 top-0 h-[2px] bg-gradient-to-r from-[var(--color-accent)]/60 via-[var(--color-accent)] to-[var(--color-accent)]/60"></div>
<!-- Brand + collapse toggle -->
<div class="flex shrink-0 items-center px-4 pt-6 pb-4 {collapsed ? 'justify-center' : 'justify-between'}">
{#if !collapsed}
<div class="flex items-center gap-2.5">
<div class="relative">
<img
src="/logo.svg"
alt="Wrenn"
class="h-7 w-7 shrink-0 rounded-[var(--radius-logo)]"
/>
</div>
<div class="flex flex-col gap-0.5 leading-none">
<span class="font-brand text-[1.286rem] text-[var(--color-text-bright)]">Wrenn</span>
<span class="inline-flex w-fit items-center gap-1 rounded-full bg-[var(--color-accent)]/15 px-1.5 py-px text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--color-accent-bright)]">
<IconShield size={8} />
Admin
</span>
</div>
</div>
{:else}
<!-- Collapsed: show shield as admin identity marker -->
<div class="flex h-7 w-7 items-center justify-center rounded-[var(--radius-button)] bg-[var(--color-accent)]/10 text-[var(--color-accent-bright)]">
<IconShield size={14} />
</div>
{/if}
<button
onclick={toggleCollapsed}
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-[var(--radius-button)] text-[var(--color-text-tertiary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-secondary)] {collapsed ? 'absolute top-5 right-1.5' : ''}"
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
<IconSidebar size={16} />
</button>
</div>
<!-- Back to dashboard -->
<div class="px-3 pb-3 {collapsed ? 'px-1.5' : ''}">
<a
href="/dashboard"
class="flex items-center rounded-[var(--radius-input)] px-2.5 py-2 text-ui text-[var(--color-text-tertiary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-secondary)] {collapsed ? 'justify-center px-2' : 'gap-2'}"
title={collapsed ? 'Back to dashboard' : undefined}
>
<IconChevron size={12} direction="left" class="shrink-0" />
{#if !collapsed}<span>Dashboard</span>{/if}
</a>
</div>
<div class="mx-4 mb-3 h-px bg-[var(--color-border)] {collapsed ? 'mx-3' : ''}"></div>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto px-3 {collapsed ? 'px-1.5' : ''}">
<div class="mb-3">
{#if !collapsed}
<div class="mb-1 px-2.5 py-1.5 text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">
Management
</div>
{:else}
<div class="mx-1 my-2 h-px bg-[var(--color-border)]"></div>
{/if}
{#each managementItems as item}
{#if isActive(item.href)}
<a
href={item.href}
class="group relative flex items-center rounded-[var(--radius-input)] bg-[var(--color-accent-glow-mid)] px-2.5 py-2.5 transition-colors duration-150 {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? item.label : undefined}
>
{#if !collapsed}
<div class="absolute left-0 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-r-full bg-[var(--color-accent)]"></div>
{/if}
<item.icon size={16} class="shrink-0 text-[var(--color-accent-bright)]" />
{#if !collapsed}
<span class="text-ui font-medium text-[var(--color-accent-bright)]">{item.label}</span>
{/if}
</a>
{:else}
<a
href={item.href}
class="group flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 transition-colors duration-150 hover:bg-[var(--color-bg-3)] {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? item.label : undefined}
>
<item.icon size={16} class="shrink-0 opacity-50 transition-opacity duration-150 group-hover:opacity-100" />
{#if !collapsed}
<span class="text-ui text-[var(--color-text-primary)] transition-colors duration-150 group-hover:text-[var(--color-text-bright)]">{item.label}</span>
{/if}
</a>
{/if}
{/each}
</div>
</nav>
<!-- Bottom links -->
<div class="shrink-0 px-3 pb-1 {collapsed ? 'px-1.5' : ''}">
<a
href="/docs"
class="group flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-primary)] {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? 'Docs' : undefined}
>
<IconDocs size={16} class="shrink-0 opacity-50 transition-opacity duration-150 group-hover:opacity-100" />
{#if !collapsed}<span class="text-ui">Docs</span>{/if}
</a>
<a
href="/dashboard/notifications"
class="group flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-primary)] {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? 'Notifications' : undefined}
>
<IconBell size={16} class="shrink-0 opacity-50 transition-opacity duration-150 group-hover:opacity-100" />
{#if !collapsed}<span class="text-ui">Notifications</span>{/if}
</a>
<a
href="/dashboard/settings"
class="group flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-primary)] {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? 'Settings' : undefined}
>
<IconSettings size={16} class="shrink-0 opacity-50 transition-opacity duration-150 group-hover:opacity-100" />
{#if !collapsed}<span class="text-ui">Settings</span>{/if}
</a>
</div>
<!-- User footer -->
<div
class="flex shrink-0 items-center border-t border-[var(--color-border)] px-3 py-2.5 {collapsed ? 'justify-center px-1.5' : 'gap-2.5'}"
>
{#if !collapsed}
<div class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[var(--color-bg-4)] text-badge font-bold uppercase text-[var(--color-text-secondary)]">
{userName[0] ?? ''}
</div>
<span class="flex-1 truncate text-ui text-[var(--color-text-secondary)]">
{userName}
</span>
{/if}
<button
onclick={() => auth.logout()}
class="flex shrink-0 items-center justify-center rounded-[var(--radius-button)] transition-colors duration-150 hover:text-[var(--color-red)] {collapsed ? 'h-7 w-7 text-[var(--color-text-muted)] hover:bg-[var(--color-bg-3)]' : 'h-6 w-6 text-[var(--color-text-tertiary)]'}"
title="Sign out"
>
<IconLogout size={collapsed ? 15 : 14} />
</button>
</div>
</aside>

View File

@ -19,7 +19,9 @@
IconSidebar,
IconBell,
IconDocs,
IconAudit
IconAudit,
IconServer,
IconShield
} from './icons';
let { collapsed = $bindable(false) }: { collapsed: boolean } = $props();
@ -39,6 +41,8 @@
label: string;
icon: typeof IconMonitor;
href: string;
disabled?: boolean;
disabledHint?: string;
};
const platformItems: NavItem[] = [
@ -46,11 +50,24 @@
{ label: 'Templates', icon: IconBox, href: '/dashboard/snapshots' }
];
const managementItems: NavItem[] = [
let currentTeamIsByoc = $derived(
teamsStore.list.find((t) => t.id === auth.teamId)?.is_byoc ?? false
);
let managementItems = $derived<NavItem[]>([
{ label: 'Keys', icon: IconKey, href: '/dashboard/keys' },
{ label: 'Team', icon: IconMembers, href: '/dashboard/team' },
{ label: 'Audit Logs', icon: IconAudit, href: '/dashboard/audit' }
];
{ label: 'Audit Logs', icon: IconAudit, href: '/dashboard/audit' },
...(currentTeamIsByoc
? [{
label: 'BYOC',
icon: IconServer,
href: '/dashboard/byoc',
disabled: auth.role === 'member',
disabledHint: 'Available to team owners and admins only'
}]
: [])
]);
const billingItems: NavItem[] = [
{ label: 'Usage', icon: IconUsage, href: '/dashboard/usage' },
@ -232,6 +249,16 @@
<!-- Bottom links -->
<div class="shrink-0 px-3 pb-1 {collapsed ? 'px-1.5' : ''}">
{#if auth.isAdmin}
<a
href="/admin"
class="group flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-primary)] {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? 'Admin' : undefined}
>
<IconShield size={16} class="shrink-0 opacity-50 transition-opacity duration-150 group-hover:opacity-100" />
{#if !collapsed}<span class="text-ui">Admin</span>{/if}
</a>
{/if}
<a
href="/docs"
class="group flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] hover:text-[var(--color-text-primary)] {collapsed ? 'justify-center px-2' : 'gap-3'}"
@ -300,7 +327,19 @@
</div>
{/if}
{#each items as item}
{#if isActive(item.href)}
{#if item.disabled}
<div
class="flex cursor-not-allowed items-center rounded-[var(--radius-input)] px-2.5 py-2.5 opacity-35 {collapsed
? 'justify-center px-2'
: 'gap-3'}"
title={collapsed ? item.disabledHint ?? item.label : item.disabledHint}
>
<item.icon size={16} class="shrink-0" />
{#if !collapsed}
<span class="text-ui text-[var(--color-text-primary)]">{item.label}</span>
{/if}
</div>
{:else if isActive(item.href)}
<a
href={item.href}
class="group relative flex items-center rounded-[var(--radius-input)] bg-[var(--color-accent-glow-mid)] px-2.5 py-2.5 transition-colors duration-150 {collapsed

View File

@ -0,0 +1,19 @@
<script lang="ts">
let { size = 18, class: className = '' }: { size?: number; class?: string } = $props();
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
class={className}
aria-hidden="true"
>
<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>

View File

@ -0,0 +1,21 @@
<script lang="ts">
let { size = 18, class: className = '' }: { size?: number; class?: string } = $props();
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
class={className}
aria-hidden="true"
>
<rect x="2" y="2" width="20" height="8" rx="2" ry="2" />
<rect x="2" y="14" width="20" height="8" rx="2" ry="2" />
<line x1="6" y1="6" x2="6.01" y2="6" />
<line x1="6" y1="18" x2="6.01" y2="18" />
</svg>

View File

@ -0,0 +1,18 @@
<script lang="ts">
let { size = 18, class: className = '' }: { size?: number; class?: string } = $props();
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
class={className}
aria-hidden="true"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>

View File

@ -23,3 +23,6 @@ export { default as IconBell } from './IconBell.svelte';
export { default as IconDocs } from './IconDocs.svelte';
export { default as IconAudit } from './IconAudit.svelte';
export { default as IconBox } from './IconBox.svelte';
export { default as IconServer } from './IconServer.svelte';
export { default as IconGear } from './IconGear.svelte';
export { default as IconShield } from './IconShield.svelte';