Skip to content

Commit c9a90c3

Browse files
committed
feat(studio): real shell — user menu + logout, workspace menu, working test mode (EBE-102/103/106)
The sidebar was static (hardcoded 'Daniel K.'/'acme-shipping', no logout). And the Topbar test-mode toggle was wired to a _app.tsx-local useState, disconnected from the data layer — so it never affected queries (the 'doesn't work across the app' bug). - UserMenu: real identity (useSession email) + dropdown → Account settings, Appearance, and **Sign out** (calls the logout server fn, clears session, redirects to /login). Production-blocking gap: there was no way to log out. - WorkspaceMenu: connected-deployment workspace + settings/connections links; honest 'Multiple organizations — Enterprise' (no fake org switch; OSS is single-tenant). - Test mode: now owned by SessionProvider (drives ctx.testMode → x-test-mode + query keys), persisted to localStorage (survives reload/nav), with a persistent banner. shell.spec: +3 tests (logout → /login, workspace menu, test-mode persists on reload). Full suite 205/205, shell 9/9. tsc/build clean.
1 parent 01b14ff commit c9a90c3

7 files changed

Lines changed: 240 additions & 22 deletions

File tree

apps/studio/src/components/shell/Sidebar.tsx

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Sidebar.tsx — mode-driven navigation (Ship / Build / Govern).
22
import { Icon, MODE_LABELS, NAV, type Mode } from "~/lib/modes";
3+
import { UserMenu } from "~/components/shell/UserMenu";
4+
import { WorkspaceMenu } from "~/components/shell/WorkspaceMenu";
35

46
const MODES: Mode[] = ["ship", "build", "govern"];
57

@@ -9,23 +11,18 @@ export function Sidebar({
911
collapsed,
1012
onGo,
1113
onMode,
14+
onTweaks,
1215
}: {
1316
route: string;
1417
mode: Mode;
1518
collapsed: boolean;
1619
onGo: (route: string) => void;
1720
onMode: (mode: Mode) => void;
21+
onTweaks: () => void;
1822
}) {
1923
return (
2024
<aside className={"sidebar" + (collapsed ? " collapsed" : "")} data-testid="sidebar">
21-
<div className="workspace" data-testid="workspace-switcher">
22-
<div className="workspace-logo">K</div>
23-
<div className="workspace-body" style={{ minWidth: 0, flex: 1 }}>
24-
<div className="workspace-name">Karrio Studio</div>
25-
<div className="workspace-mode">acme-shipping</div>
26-
</div>
27-
<Icon.ChevronD size={12} className="workspace-chev" style={{ color: "var(--fg-subtle)" }} />
28-
</div>
25+
<WorkspaceMenu onGo={onGo} />
2926

3027
<div className="modes" role="tablist" aria-label="Mode" data-testid="mode-switch">
3128
{MODES.map((m) => {
@@ -82,16 +79,7 @@ export function Sidebar({
8279
))}
8380
</nav>
8481

85-
<div className="sidebar-foot">
86-
<div className="avatar">DK</div>
87-
<div className="grow user-meta" style={{ minWidth: 0 }}>
88-
<div className="user-name">Daniel K.</div>
89-
<div className="user-role">Owner</div>
90-
</div>
91-
<span className="icon-action user-chev">
92-
<Icon.ChevronD size={12} />
93-
</span>
94-
</div>
82+
<UserMenu onGo={onGo} onTweaks={onTweaks} />
9583
</aside>
9684
);
9785
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// UserMenu.tsx — sidebar-foot account menu: real user identity + sign out.
2+
import { useEffect, useRef, useState } from "react";
3+
import { useQueryClient } from "@tanstack/react-query";
4+
import { Icon } from "~/components/ui/icons";
5+
import { useSession } from "~/lib/karrio/session";
6+
import { logout } from "~/server/auth";
7+
8+
export function UserMenu({ onGo, onTweaks }: { onGo: (route: string) => void; onTweaks: () => void }) {
9+
const { email } = useSession();
10+
const qc = useQueryClient();
11+
const [open, setOpen] = useState(false);
12+
const ref = useRef<HTMLDivElement>(null);
13+
14+
useEffect(() => {
15+
if (!open) return;
16+
const onDoc = (e: MouseEvent) => {
17+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
18+
};
19+
document.addEventListener("mousedown", onDoc);
20+
return () => document.removeEventListener("mousedown", onDoc);
21+
}, [open]);
22+
23+
const name = email ?? "Account";
24+
const initials = (email ?? "U").replace(/@.*/, "").slice(0, 2).toUpperCase();
25+
26+
const signOut = async () => {
27+
try {
28+
await logout();
29+
} catch {
30+
/* even if the server call fails, clear the client session */
31+
}
32+
qc.setQueryData(["studio-session"], null);
33+
window.location.assign("/login"); // hard nav clears all in-memory state
34+
};
35+
36+
return (
37+
<div className="user-menu-wrap" ref={ref} style={{ position: "relative" }}>
38+
<button
39+
type="button"
40+
className="sidebar-foot"
41+
onClick={() => setOpen((o) => !o)}
42+
data-testid="user-menu-trigger"
43+
aria-haspopup="menu"
44+
aria-expanded={open}
45+
style={{ width: "100%", background: "none", border: "none", cursor: "pointer", textAlign: "left", font: "inherit", color: "inherit" }}
46+
>
47+
<div className="avatar">{initials}</div>
48+
<div className="grow user-meta" style={{ minWidth: 0 }}>
49+
<div className="user-name">{name}</div>
50+
<div className="user-role">Signed in</div>
51+
</div>
52+
<span className="icon-action user-chev"><Icon.ChevronD size={12} /></span>
53+
</button>
54+
55+
{open && (
56+
<div className="menu" data-testid="user-menu" role="menu" style={{ position: "absolute", bottom: "calc(100% + 8px)", left: 0, right: 0, zIndex: 50 }}>
57+
<div style={{ padding: "8px 12px", borderBottom: "1px solid var(--border)", fontSize: 12 }}>
58+
<div className="user-name" style={{ fontSize: 12.5 }}>{name}</div>
59+
<div className="muted" style={{ fontSize: 11 }}>{email ? "Account" : "Not signed in"}</div>
60+
</div>
61+
<div className="menu-item" role="menuitem" data-testid="user-menu-settings" onClick={() => { setOpen(false); onGo("settings"); }}>
62+
<span className="icon"><Icon.Settings size={14} /></span><span>Account settings</span>
63+
</div>
64+
<div className="menu-item" role="menuitem" data-testid="user-menu-appearance" onClick={() => { setOpen(false); onTweaks(); }}>
65+
<span className="icon"><Icon.Sliders size={14} /></span><span>Appearance</span>
66+
</div>
67+
<div className="menu-sep" />
68+
<div className="menu-item" role="menuitem" data-testid="user-menu-logout" onClick={signOut}>
69+
<span className="icon"><Icon.Lock size={14} /></span><span>Sign out</span>
70+
</div>
71+
</div>
72+
)}
73+
</div>
74+
);
75+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// WorkspaceMenu.tsx — sidebar workspace/org switcher. OSS Karrio is single-tenant
2+
// (no `organizations` in the GraphQL schema), so this presents the connected
3+
// workspace honestly + settings, and notes that multi-org is an Enterprise
4+
// feature rather than faking an org switch.
5+
import { useEffect, useRef, useState } from "react";
6+
import { Icon } from "~/components/ui/icons";
7+
import { useSession } from "~/lib/karrio/session";
8+
9+
export function WorkspaceMenu({ onGo }: { onGo: (route: string) => void }) {
10+
const { email, ctx } = useSession();
11+
const [open, setOpen] = useState(false);
12+
const ref = useRef<HTMLDivElement>(null);
13+
14+
useEffect(() => {
15+
if (!open) return;
16+
const onDoc = (e: MouseEvent) => {
17+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
18+
};
19+
document.addEventListener("mousedown", onDoc);
20+
return () => document.removeEventListener("mousedown", onDoc);
21+
}, [open]);
22+
23+
// Honest workspace identity: the connected Karrio deployment host.
24+
let host = "workspace";
25+
try {
26+
host = new URL(ctx.baseUrl).host;
27+
} catch {
28+
/* keep fallback */
29+
}
30+
31+
return (
32+
<div ref={ref} style={{ position: "relative" }}>
33+
<button
34+
type="button"
35+
className="workspace"
36+
data-testid="workspace-switcher"
37+
onClick={() => setOpen((o) => !o)}
38+
aria-haspopup="menu"
39+
aria-expanded={open}
40+
style={{ width: "100%", background: "none", border: "none", cursor: "pointer", textAlign: "left", font: "inherit", color: "inherit" }}
41+
>
42+
<div className="workspace-logo">K</div>
43+
<div className="workspace-body" style={{ minWidth: 0, flex: 1 }}>
44+
<div className="workspace-name">Karrio Studio</div>
45+
<div className="workspace-mode">{host}</div>
46+
</div>
47+
<Icon.ChevronD size={12} className="workspace-chev" style={{ color: "var(--fg-subtle)" }} />
48+
</button>
49+
50+
{open && (
51+
<div className="menu" data-testid="workspace-menu" role="menu" style={{ position: "absolute", top: "calc(100% + 8px)", left: 0, right: 0, zIndex: 50 }}>
52+
<div style={{ padding: "8px 12px", borderBottom: "1px solid var(--border)" }}>
53+
<div className="workspace-name" style={{ fontSize: 12.5 }}>Karrio Studio</div>
54+
<div className="muted" style={{ fontSize: 11 }}>{email ?? host}</div>
55+
</div>
56+
<div className="menu-item" role="menuitem" data-testid="workspace-menu-settings" onClick={() => { setOpen(false); onGo("settings"); }}>
57+
<span className="icon"><Icon.Settings size={14} /></span><span>Workspace settings</span>
58+
</div>
59+
<div className="menu-item" role="menuitem" data-testid="workspace-menu-connections" onClick={() => { setOpen(false); onGo("connections"); }}>
60+
<span className="icon"><Icon.Plug size={14} /></span><span>Carrier connections</span>
61+
</div>
62+
<div className="menu-sep" />
63+
<div className="menu-item" role="menuitem" aria-disabled="true" style={{ opacity: 0.7, cursor: "default" }} data-testid="workspace-menu-orgs">
64+
<span className="icon"><Icon.Shield size={14} /></span>
65+
<span>Multiple organizations<span className="muted" style={{ fontSize: 10, marginLeft: 6 }}>Enterprise</span></span>
66+
</div>
67+
</div>
68+
)}
69+
</div>
70+
);
71+
}

apps/studio/src/lib/karrio/session.tsx

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,46 @@ type SessionContextValue = {
2929

3030
const SessionContext = createContext<SessionContextValue | null>(null);
3131

32+
const TEST_MODE_KEY = "karrio-studio:test-mode";
33+
const ORG_KEY = "karrio-studio:org-id";
34+
const lsGet = (k: string): string | null => {
35+
try {
36+
return typeof localStorage !== "undefined" ? localStorage.getItem(k) : null;
37+
} catch {
38+
return null;
39+
}
40+
};
41+
const lsSet = (k: string, v: string | null): void => {
42+
try {
43+
if (typeof localStorage === "undefined") return;
44+
if (v === null) localStorage.removeItem(k);
45+
else localStorage.setItem(k, v);
46+
} catch {
47+
/* storage may be unavailable */
48+
}
49+
};
50+
3251
export function SessionProvider({ children }: { children: ReactNode }) {
3352
const baseUrl = karrioBaseUrl();
3453
const queryClient = useQueryClient();
35-
const [testMode, setTestMode] = useState(false);
36-
const [orgId, setOrgId] = useState<string | undefined>(undefined);
54+
const [testMode, setTestModeState] = useState(false);
55+
const [orgId, setOrgIdState] = useState<string | undefined>(undefined);
56+
57+
// Hydrate test-mode + org from localStorage on the client (avoids SSR mismatch),
58+
// so the selected mode persists across reloads and navigation.
59+
useEffect(() => {
60+
setTestModeState(lsGet(TEST_MODE_KEY) === "1");
61+
setOrgIdState(lsGet(ORG_KEY) ?? undefined);
62+
}, []);
63+
64+
const setTestMode = (on: boolean) => {
65+
lsSet(TEST_MODE_KEY, on ? "1" : "0");
66+
setTestModeState(on);
67+
};
68+
const setOrgId = (id?: string) => {
69+
lsSet(ORG_KEY, id ?? null);
70+
setOrgIdState(id);
71+
};
3772

3873
const sessionQuery = useQuery({
3974
queryKey: ["studio-session"],

apps/studio/src/routes/_app.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { CommandPalette } from "~/components/overlays/CommandPalette";
77
import { Workbench } from "~/components/overlays/Workbench";
88
import { TweaksPanel } from "~/components/overlays/TweaksPanel";
99
import { MODE_DEFAULTS, routeMode, type Mode } from "~/lib/modes";
10+
import { useSession } from "~/lib/karrio/session";
1011
import {
1112
applyStoredTweaks,
1213
getSidebarCollapsed,
@@ -32,9 +33,11 @@ function AppLayout() {
3233
const route = params.screen ?? "home";
3334
const mode = routeMode(route);
3435

36+
// Test mode is owned by SessionProvider (it drives ctx.testMode → x-test-mode
37+
// and the React Query keys), so the toggle actually affects data across the app.
38+
const { testMode, setTestMode } = useSession();
3539
const [theme, setTheme] = useState<Theme>("dark");
3640
const [collapsed, setCollapsed] = useState(false);
37-
const [testMode, setTestMode] = useState(false);
3841
const [navOpen, setNavOpen] = useState(false); // mobile off-canvas drawer
3942
const [paletteOpen, setPaletteOpen] = useState(false);
4043
const [workbenchOpen, setWorkbenchOpen] = useState(false);
@@ -136,8 +139,13 @@ function AppLayout() {
136139
{navOpen && (
137140
<div className="nav-backdrop" onClick={() => setNavOpen(false)} data-testid="nav-backdrop" aria-hidden="true" />
138141
)}
139-
<Sidebar route={route} mode={mode} collapsed={collapsed} onGo={go} onMode={onMode} />
142+
<Sidebar route={route} mode={mode} collapsed={collapsed} onGo={go} onMode={onMode} onTweaks={() => setTweaksOpen(true)} />
140143
<div className="main">
144+
{testMode && (
145+
<div className="test-mode-banner" data-testid="test-mode-banner" role="status">
146+
<span className="test-mode-dot" /> Test mode — showing sandbox data. Actions won’t affect live shipments.
147+
</div>
148+
)}
141149
<Topbar
142150
mode={mode}
143151
route={route}

apps/studio/src/styles/tokens.css

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,20 @@ input, textarea, select { font-family: inherit; color: inherit; }
296296
}
297297
.menu-sep { height: 1px; background: var(--border); margin: 4px 0; }
298298

299+
/* Test-mode banner — persistent indicator that the app is in sandbox mode. */
300+
.test-mode-banner {
301+
display: flex; align-items: center; gap: 8px;
302+
padding: 6px 16px; font-size: 12px; font-weight: 500;
303+
color: var(--amber-fg, #b45309);
304+
background: var(--amber-bg, rgba(245, 158, 11, 0.12));
305+
border-bottom: 1px solid var(--amber-border, rgba(245, 158, 11, 0.3));
306+
}
307+
.test-mode-dot {
308+
width: 7px; height: 7px; border-radius: 50%;
309+
background: var(--amber-fg, #f59e0b);
310+
box-shadow: 0 0 0 3px var(--amber-bg, rgba(245, 158, 11, 0.18));
311+
}
312+
299313
/* ===================== Page ===================== */
300314
.page { flex: 1; overflow-y: auto; padding: 20px 24px 80px; }
301315
.page-header { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }

packages/e2e/tests/studio/shell.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,31 @@ test.describe("Studio shell", () => {
6161
await expect(menu.getByText("New shipment")).toBeVisible();
6262
await expect(menu.getByText("Generate API key")).toBeVisible();
6363
});
64+
65+
test("user menu opens and signs out to /login", async ({ page }) => {
66+
await gotoStudio(page, "home");
67+
await page.getByTestId("user-menu-trigger").click();
68+
await expect(page.getByTestId("user-menu")).toBeVisible();
69+
await expect(page.getByTestId("user-menu-settings")).toBeVisible();
70+
await page.getByTestId("user-menu-logout").click();
71+
await expect(page).toHaveURL(/\/login$/);
72+
});
73+
74+
test("workspace menu opens with settings + honest org state", async ({ page }) => {
75+
await gotoStudio(page, "home");
76+
await page.getByTestId("workspace-switcher").click();
77+
const menu = page.getByTestId("workspace-menu");
78+
await expect(menu).toBeVisible();
79+
await expect(page.getByTestId("workspace-menu-settings")).toBeVisible();
80+
await expect(page.getByTestId("workspace-menu-orgs")).toContainText(/Enterprise/i);
81+
});
82+
83+
test("test mode toggles a banner and persists across reload", async ({ page }) => {
84+
await gotoStudio(page, "home");
85+
await expect(page.getByTestId("test-mode-banner")).toHaveCount(0);
86+
await page.getByTestId("test-mode").click();
87+
await expect(page.getByTestId("test-mode-banner")).toBeVisible();
88+
await page.reload();
89+
await expect(page.getByTestId("test-mode-banner")).toBeVisible(); // persisted
90+
});
6491
});

0 commit comments

Comments
 (0)