@stigmer/react 0.0.99 → 0.0.101
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +17 -3
- package/index.d.ts.map +1 -1
- package/index.js +16 -3
- package/index.js.map +1 -1
- package/internal/menu.d.ts +17 -0
- package/internal/menu.d.ts.map +1 -0
- package/internal/menu.js +43 -0
- package/internal/menu.js.map +1 -0
- package/library/LibraryBreadcrumbContext.d.ts +30 -0
- package/library/LibraryBreadcrumbContext.d.ts.map +1 -0
- package/library/LibraryBreadcrumbContext.js +39 -0
- package/library/LibraryBreadcrumbContext.js.map +1 -0
- package/library/index.d.ts +1 -0
- package/library/index.d.ts.map +1 -1
- package/library/index.js +1 -0
- package/library/index.js.map +1 -1
- package/organization/OrgProvider.d.ts +59 -0
- package/organization/OrgProvider.d.ts.map +1 -0
- package/organization/OrgProvider.js +130 -0
- package/organization/OrgProvider.js.map +1 -0
- package/organization/OrgSwitcher.d.ts +36 -0
- package/organization/OrgSwitcher.d.ts.map +1 -0
- package/organization/OrgSwitcher.js +73 -0
- package/organization/OrgSwitcher.js.map +1 -0
- package/organization/index.d.ts +6 -0
- package/organization/index.d.ts.map +1 -1
- package/organization/index.js +3 -0
- package/organization/index.js.map +1 -1
- package/organization/useOrgGate.d.ts +101 -0
- package/organization/useOrgGate.d.ts.map +1 -0
- package/organization/useOrgGate.js +99 -0
- package/organization/useOrgGate.js.map +1 -0
- package/package.json +5 -4
- package/runner/RunnerListPanel.d.ts +13 -8
- package/runner/RunnerListPanel.d.ts.map +1 -1
- package/runner/RunnerListPanel.js +10 -6
- package/runner/RunnerListPanel.js.map +1 -1
- package/settings/ApiKeysSection.d.ts +3 -0
- package/settings/ApiKeysSection.d.ts.map +1 -0
- package/settings/ApiKeysSection.js +30 -0
- package/settings/ApiKeysSection.js.map +1 -0
- package/settings/EnvironmentsSection.d.ts +3 -0
- package/settings/EnvironmentsSection.d.ts.map +1 -0
- package/settings/EnvironmentsSection.js +49 -0
- package/settings/EnvironmentsSection.js.map +1 -0
- package/settings/IdentityProvidersSection.d.ts +12 -0
- package/settings/IdentityProvidersSection.d.ts.map +1 -0
- package/settings/IdentityProvidersSection.js +34 -0
- package/settings/IdentityProvidersSection.js.map +1 -0
- package/settings/InvitationsSection.d.ts +3 -0
- package/settings/InvitationsSection.d.ts.map +1 -0
- package/settings/InvitationsSection.js +13 -0
- package/settings/InvitationsSection.js.map +1 -0
- package/settings/MembersSection.d.ts +3 -0
- package/settings/MembersSection.d.ts.map +1 -0
- package/settings/MembersSection.js +14 -0
- package/settings/MembersSection.js.map +1 -0
- package/settings/OAuthAppsSection.d.ts +3 -0
- package/settings/OAuthAppsSection.d.ts.map +1 -0
- package/settings/OAuthAppsSection.js +33 -0
- package/settings/OAuthAppsSection.js.map +1 -0
- package/settings/OrgProfileSection.d.ts +3 -0
- package/settings/OrgProfileSection.d.ts.map +1 -0
- package/settings/OrgProfileSection.js +15 -0
- package/settings/OrgProfileSection.js.map +1 -0
- package/settings/PlatformClientsSection.d.ts +3 -0
- package/settings/PlatformClientsSection.d.ts.map +1 -0
- package/settings/PlatformClientsSection.js +48 -0
- package/settings/PlatformClientsSection.js.map +1 -0
- package/settings/UsageSection.d.ts +3 -0
- package/settings/UsageSection.d.ts.map +1 -0
- package/settings/UsageSection.js +14 -0
- package/settings/UsageSection.js.map +1 -0
- package/settings/index.d.ts +13 -0
- package/settings/index.d.ts.map +1 -0
- package/settings/index.js +11 -0
- package/settings/index.js.map +1 -0
- package/settings/settings-nav.d.ts +25 -0
- package/settings/settings-nav.d.ts.map +1 -0
- package/settings/settings-nav.js +41 -0
- package/settings/settings-nav.js.map +1 -0
- package/src/index.ts +32 -1
- package/src/internal/menu.tsx +160 -0
- package/src/library/LibraryBreadcrumbContext.tsx +70 -0
- package/src/library/index.ts +6 -0
- package/src/organization/OrgProvider.tsx +184 -0
- package/src/organization/OrgSwitcher.tsx +275 -0
- package/src/organization/index.ts +10 -0
- package/src/organization/useOrgGate.ts +183 -0
- package/src/runner/RunnerListPanel.tsx +14 -9
- package/src/settings/ApiKeysSection.tsx +96 -0
- package/src/settings/EnvironmentsSection.tsx +162 -0
- package/src/settings/IdentityProvidersSection.tsx +123 -0
- package/src/settings/InvitationsSection.tsx +42 -0
- package/src/settings/MembersSection.tsx +41 -0
- package/src/settings/OAuthAppsSection.tsx +100 -0
- package/src/settings/OrgProfileSection.tsx +41 -0
- package/src/settings/PlatformClientsSection.tsx +149 -0
- package/src/settings/UsageSection.tsx +41 -0
- package/src/settings/index.ts +13 -0
- package/src/settings/settings-nav.ts +78 -0
- package/src/user/UserMenu.tsx +241 -0
- package/src/user/index.ts +2 -0
- package/styles.css +1 -1
- package/user/UserMenu.d.ts +82 -0
- package/user/UserMenu.d.ts.map +1 -0
- package/user/UserMenu.js +51 -0
- package/user/UserMenu.js.map +1 -0
- package/user/index.d.ts +3 -0
- package/user/index.d.ts.map +1 -0
- package/user/index.js +2 -0
- package/user/index.js.map +1 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
|
|
5
|
+
import { cn } from "@stigmer/theme";
|
|
6
|
+
import { CheckIcon } from "lucide-react";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// SDK-internal styled Menu primitives over @base-ui/react.
|
|
10
|
+
//
|
|
11
|
+
// These are NOT exported from @stigmer/react. They provide a single source
|
|
12
|
+
// of truth for dropdown menu styling across SDK styled components
|
|
13
|
+
// (OrgSwitcher, UserMenu, etc.) so that every menu looks identical.
|
|
14
|
+
//
|
|
15
|
+
// Portaled content uses popover-* / main-area tokens per DD-005.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
function Menu(props: MenuPrimitive.Root.Props) {
|
|
19
|
+
return <MenuPrimitive.Root {...props} />;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function MenuTrigger(props: MenuPrimitive.Trigger.Props) {
|
|
23
|
+
return <MenuPrimitive.Trigger {...props} />;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function MenuContent({
|
|
27
|
+
align = "start",
|
|
28
|
+
alignOffset = 0,
|
|
29
|
+
side = "bottom",
|
|
30
|
+
sideOffset = 4,
|
|
31
|
+
className,
|
|
32
|
+
...props
|
|
33
|
+
}: MenuPrimitive.Popup.Props &
|
|
34
|
+
Pick<
|
|
35
|
+
MenuPrimitive.Positioner.Props,
|
|
36
|
+
"align" | "alignOffset" | "side" | "sideOffset"
|
|
37
|
+
>) {
|
|
38
|
+
return (
|
|
39
|
+
<MenuPrimitive.Portal>
|
|
40
|
+
<MenuPrimitive.Positioner
|
|
41
|
+
className="isolate z-50 outline-none"
|
|
42
|
+
align={align}
|
|
43
|
+
alignOffset={alignOffset}
|
|
44
|
+
side={side}
|
|
45
|
+
sideOffset={sideOffset}
|
|
46
|
+
>
|
|
47
|
+
<MenuPrimitive.Popup
|
|
48
|
+
className={cn(
|
|
49
|
+
"bg-popover text-popover-foreground ring-foreground/10",
|
|
50
|
+
"data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
51
|
+
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
|
|
52
|
+
"data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
|
53
|
+
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg p-1 shadow-md ring-1 duration-100 outline-none data-closed:overflow-hidden",
|
|
54
|
+
className,
|
|
55
|
+
)}
|
|
56
|
+
{...props}
|
|
57
|
+
/>
|
|
58
|
+
</MenuPrimitive.Positioner>
|
|
59
|
+
</MenuPrimitive.Portal>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function MenuItem({
|
|
64
|
+
className,
|
|
65
|
+
variant = "default",
|
|
66
|
+
...props
|
|
67
|
+
}: MenuPrimitive.Item.Props & {
|
|
68
|
+
variant?: "default" | "destructive";
|
|
69
|
+
}) {
|
|
70
|
+
return (
|
|
71
|
+
<MenuPrimitive.Item
|
|
72
|
+
data-variant={variant}
|
|
73
|
+
className={cn(
|
|
74
|
+
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground",
|
|
75
|
+
"data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive-subtle data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive",
|
|
76
|
+
"relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none",
|
|
77
|
+
"data-disabled:pointer-events-none data-disabled:opacity-50",
|
|
78
|
+
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
79
|
+
className,
|
|
80
|
+
)}
|
|
81
|
+
{...props}
|
|
82
|
+
/>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function MenuRadioGroup(props: MenuPrimitive.RadioGroup.Props) {
|
|
87
|
+
return <MenuPrimitive.RadioGroup {...props} />;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function MenuRadioItem({
|
|
91
|
+
className,
|
|
92
|
+
children,
|
|
93
|
+
...props
|
|
94
|
+
}: MenuPrimitive.RadioItem.Props) {
|
|
95
|
+
return (
|
|
96
|
+
<MenuPrimitive.RadioItem
|
|
97
|
+
className={cn(
|
|
98
|
+
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground",
|
|
99
|
+
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none",
|
|
100
|
+
"data-disabled:pointer-events-none data-disabled:opacity-50",
|
|
101
|
+
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
102
|
+
className,
|
|
103
|
+
)}
|
|
104
|
+
{...props}
|
|
105
|
+
>
|
|
106
|
+
<span className="pointer-events-none absolute right-2 flex items-center justify-center">
|
|
107
|
+
<MenuPrimitive.RadioItemIndicator>
|
|
108
|
+
<CheckIcon />
|
|
109
|
+
</MenuPrimitive.RadioItemIndicator>
|
|
110
|
+
</span>
|
|
111
|
+
{children}
|
|
112
|
+
</MenuPrimitive.RadioItem>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function MenuSeparator({
|
|
117
|
+
className,
|
|
118
|
+
...props
|
|
119
|
+
}: MenuPrimitive.Separator.Props) {
|
|
120
|
+
return (
|
|
121
|
+
<MenuPrimitive.Separator
|
|
122
|
+
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
|
123
|
+
{...props}
|
|
124
|
+
/>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function MenuGroup({
|
|
129
|
+
className,
|
|
130
|
+
...props
|
|
131
|
+
}: React.ComponentPropsWithoutRef<"div"> & { role?: string }) {
|
|
132
|
+
return <div role="group" className={className} {...props} />;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function MenuLabel({
|
|
136
|
+
className,
|
|
137
|
+
...props
|
|
138
|
+
}: React.ComponentPropsWithoutRef<"span">) {
|
|
139
|
+
return (
|
|
140
|
+
<span
|
|
141
|
+
className={cn(
|
|
142
|
+
"text-muted-foreground block px-1.5 py-1 text-[11px] font-medium uppercase tracking-wider select-none",
|
|
143
|
+
className,
|
|
144
|
+
)}
|
|
145
|
+
{...props}
|
|
146
|
+
/>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export {
|
|
151
|
+
Menu,
|
|
152
|
+
MenuTrigger,
|
|
153
|
+
MenuContent,
|
|
154
|
+
MenuItem,
|
|
155
|
+
MenuRadioGroup,
|
|
156
|
+
MenuRadioItem,
|
|
157
|
+
MenuSeparator,
|
|
158
|
+
MenuGroup,
|
|
159
|
+
MenuLabel,
|
|
160
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useMemo,
|
|
8
|
+
useState,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from "react";
|
|
11
|
+
|
|
12
|
+
interface BreadcrumbOverrideContextValue {
|
|
13
|
+
readonly label: string | null;
|
|
14
|
+
readonly setLabel: (label: string | null) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const BreadcrumbOverrideContext =
|
|
18
|
+
createContext<BreadcrumbOverrideContextValue | null>(null);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Provides a breadcrumb label override for library detail pages.
|
|
22
|
+
*
|
|
23
|
+
* Wrap the library zone in this provider so that detail pages can push
|
|
24
|
+
* a human-readable resource name into the breadcrumb via
|
|
25
|
+
* `useBreadcrumbOverride().setLabel(name)`.
|
|
26
|
+
*/
|
|
27
|
+
export function LibraryBreadcrumbProvider({
|
|
28
|
+
children,
|
|
29
|
+
}: {
|
|
30
|
+
readonly children: ReactNode;
|
|
31
|
+
}) {
|
|
32
|
+
const [label, setLabelState] = useState<string | null>(null);
|
|
33
|
+
const setLabel = useCallback(
|
|
34
|
+
(next: string | null) => setLabelState(next),
|
|
35
|
+
[],
|
|
36
|
+
);
|
|
37
|
+
const value = useMemo(() => ({ label, setLabel }), [label, setLabel]);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<BreadcrumbOverrideContext.Provider value={value}>
|
|
41
|
+
{children}
|
|
42
|
+
</BreadcrumbOverrideContext.Provider>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read the current breadcrumb override label.
|
|
48
|
+
*
|
|
49
|
+
* Used by breadcrumb UI components to display a resource display name
|
|
50
|
+
* instead of the raw URL slug for the last segment.
|
|
51
|
+
*/
|
|
52
|
+
export function useBreadcrumbLabel(): string | null {
|
|
53
|
+
return useContext(BreadcrumbOverrideContext)?.label ?? null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Set (or clear) the breadcrumb override label.
|
|
58
|
+
*
|
|
59
|
+
* Used by detail pages to push the resource display name up to the
|
|
60
|
+
* breadcrumb after the resource data has loaded. Returns a stable
|
|
61
|
+
* `setLabel` function safe for use as a `useEffect` dependency.
|
|
62
|
+
*/
|
|
63
|
+
export function useBreadcrumbOverride(): {
|
|
64
|
+
/** Set or clear the breadcrumb label override for the active detail page. */
|
|
65
|
+
setLabel: (label: string | null) => void;
|
|
66
|
+
} {
|
|
67
|
+
const ctx = useContext(BreadcrumbOverrideContext);
|
|
68
|
+
const noop = useCallback((_label: string | null) => {}, []);
|
|
69
|
+
return { setLabel: ctx?.setLabel ?? noop };
|
|
70
|
+
}
|
package/src/library/index.ts
CHANGED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
} from "react";
|
|
13
|
+
import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
|
|
14
|
+
import { useStigmer } from "../hooks";
|
|
15
|
+
|
|
16
|
+
/** Value exposed by {@link OrgProvider} via {@link useOrg}. */
|
|
17
|
+
export interface OrgContextValue {
|
|
18
|
+
/** All organizations the authenticated user belongs to. */
|
|
19
|
+
readonly orgs: Organization[];
|
|
20
|
+
/** The currently selected organization. Null while loading or if the user has no orgs. */
|
|
21
|
+
readonly activeOrg: Organization | null;
|
|
22
|
+
/** Switch the active organization. Persisted to localStorage. */
|
|
23
|
+
readonly setActiveOrg: (org: Organization) => void;
|
|
24
|
+
/** True during the initial fetch of organizations. */
|
|
25
|
+
readonly isLoading: boolean;
|
|
26
|
+
/** Non-null when the fetch failed. */
|
|
27
|
+
readonly error: string | null;
|
|
28
|
+
/** Re-attempt the organization fetch after a failure. */
|
|
29
|
+
readonly retry: () => void;
|
|
30
|
+
/**
|
|
31
|
+
* Refetch the organization list. If `targetSlug` is provided, the
|
|
32
|
+
* org matching that slug will be auto-selected after the fetch
|
|
33
|
+
* completes (useful after creating a new organization).
|
|
34
|
+
*/
|
|
35
|
+
readonly refresh: (targetSlug?: string) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const OrgContext = createContext<OrgContextValue | null>(null);
|
|
39
|
+
|
|
40
|
+
const STORAGE_KEY = "stigmer:activeOrgSlug";
|
|
41
|
+
|
|
42
|
+
function readPersistedSlug(): string | null {
|
|
43
|
+
try {
|
|
44
|
+
return localStorage.getItem(STORAGE_KEY);
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function persistSlug(slug: string): void {
|
|
51
|
+
try {
|
|
52
|
+
localStorage.setItem(STORAGE_KEY, slug);
|
|
53
|
+
} catch {
|
|
54
|
+
// SSR or private browsing — silently ignore.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Provides organization context to the component tree.
|
|
60
|
+
*
|
|
61
|
+
* Fetches the authenticated user's organizations via
|
|
62
|
+
* `stigmer.organization.findMyOrganizations()`, manages the active
|
|
63
|
+
* organization selection, and persists the choice to `localStorage`
|
|
64
|
+
* under the key `stigmer:activeOrgSlug`.
|
|
65
|
+
*
|
|
66
|
+
* Must be rendered inside a {@link StigmerProvider}.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```tsx
|
|
70
|
+
* <StigmerProvider client={client}>
|
|
71
|
+
* <OrgProvider>
|
|
72
|
+
* <App />
|
|
73
|
+
* </OrgProvider>
|
|
74
|
+
* </StigmerProvider>
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export function OrgProvider({ children }: { children: ReactNode }) {
|
|
78
|
+
const stigmer = useStigmer();
|
|
79
|
+
const [orgs, setOrgs] = useState<Organization[]>([]);
|
|
80
|
+
const [activeOrg, setActiveOrgState] = useState<Organization | null>(null);
|
|
81
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
82
|
+
const [error, setError] = useState<string | null>(null);
|
|
83
|
+
|
|
84
|
+
const fetchIdRef = useRef(0);
|
|
85
|
+
|
|
86
|
+
const load = useCallback(
|
|
87
|
+
async (targetSlug?: string) => {
|
|
88
|
+
const fetchId = ++fetchIdRef.current;
|
|
89
|
+
setIsLoading(true);
|
|
90
|
+
setError(null);
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const response = await stigmer.organization.findMyOrganizations();
|
|
94
|
+
const entries = response.entries;
|
|
95
|
+
|
|
96
|
+
if (fetchId !== fetchIdRef.current) return;
|
|
97
|
+
|
|
98
|
+
setOrgs(entries);
|
|
99
|
+
|
|
100
|
+
if (entries.length === 0) {
|
|
101
|
+
setActiveOrgState(null);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const preferred = targetSlug ?? readPersistedSlug();
|
|
106
|
+
const restored = preferred
|
|
107
|
+
? entries.find((o) => o.metadata?.slug === preferred)
|
|
108
|
+
: undefined;
|
|
109
|
+
|
|
110
|
+
const selected = restored ?? entries[0];
|
|
111
|
+
setActiveOrgState(selected);
|
|
112
|
+
if (selected.metadata?.slug) {
|
|
113
|
+
persistSlug(selected.metadata.slug);
|
|
114
|
+
}
|
|
115
|
+
} catch (err: unknown) {
|
|
116
|
+
if (fetchId !== fetchIdRef.current) return;
|
|
117
|
+
|
|
118
|
+
const message =
|
|
119
|
+
err instanceof Error ? err.message : "Failed to load organizations";
|
|
120
|
+
setError(message);
|
|
121
|
+
setOrgs([]);
|
|
122
|
+
setActiveOrgState(null);
|
|
123
|
+
} finally {
|
|
124
|
+
if (fetchId === fetchIdRef.current) {
|
|
125
|
+
setIsLoading(false);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
[stigmer],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
load();
|
|
134
|
+
}, [load]);
|
|
135
|
+
|
|
136
|
+
const setActiveOrg = useCallback((org: Organization) => {
|
|
137
|
+
setActiveOrgState(org);
|
|
138
|
+
if (org.metadata?.slug) {
|
|
139
|
+
persistSlug(org.metadata.slug);
|
|
140
|
+
}
|
|
141
|
+
}, []);
|
|
142
|
+
|
|
143
|
+
const value = useMemo<OrgContextValue>(
|
|
144
|
+
() => ({
|
|
145
|
+
orgs,
|
|
146
|
+
activeOrg,
|
|
147
|
+
setActiveOrg,
|
|
148
|
+
isLoading,
|
|
149
|
+
error,
|
|
150
|
+
retry: load,
|
|
151
|
+
refresh: load,
|
|
152
|
+
}),
|
|
153
|
+
[orgs, activeOrg, setActiveOrg, isLoading, error, load],
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
return <OrgContext.Provider value={value}>{children}</OrgContext.Provider>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Access the active organization context from the nearest
|
|
161
|
+
* {@link OrgProvider}.
|
|
162
|
+
*
|
|
163
|
+
* Throws if called outside an `<OrgProvider>` — this surfaces wiring
|
|
164
|
+
* mistakes immediately during development.
|
|
165
|
+
*/
|
|
166
|
+
export function useOrg(): OrgContextValue {
|
|
167
|
+
const ctx = useContext(OrgContext);
|
|
168
|
+
if (!ctx) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
"useOrg must be used within <OrgProvider>. " +
|
|
171
|
+
"Wrap your component tree with <OrgProvider> inside a <StigmerProvider>.",
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return ctx;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Convenience accessor: returns the active org's slug for use in API
|
|
179
|
+
* calls, or an empty string when no org is selected.
|
|
180
|
+
*/
|
|
181
|
+
export function useActiveOrgSlug(): string {
|
|
182
|
+
const { activeOrg } = useOrg();
|
|
183
|
+
return activeOrg?.metadata?.slug ?? "";
|
|
184
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useMemo, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
Building2,
|
|
6
|
+
AlertCircle,
|
|
7
|
+
RefreshCw,
|
|
8
|
+
ChevronsUpDown,
|
|
9
|
+
Plus,
|
|
10
|
+
User,
|
|
11
|
+
} from "lucide-react";
|
|
12
|
+
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
|
|
13
|
+
import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
|
|
14
|
+
import { cn } from "@stigmer/theme";
|
|
15
|
+
import {
|
|
16
|
+
Menu,
|
|
17
|
+
MenuContent,
|
|
18
|
+
MenuItem,
|
|
19
|
+
MenuRadioGroup,
|
|
20
|
+
MenuRadioItem,
|
|
21
|
+
MenuSeparator,
|
|
22
|
+
MenuTrigger,
|
|
23
|
+
} from "../internal/menu";
|
|
24
|
+
import { useOrg } from "./OrgProvider";
|
|
25
|
+
import { CreateOrganizationForm } from "./CreateOrganizationForm";
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Public API
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/** Props for {@link OrgSwitcher}. */
|
|
32
|
+
export interface OrgSwitcherProps {
|
|
33
|
+
/**
|
|
34
|
+
* Called when the user explicitly switches to a different organization
|
|
35
|
+
* or creates a new one.
|
|
36
|
+
*
|
|
37
|
+
* This fires only on user-initiated changes — not on initial load,
|
|
38
|
+
* background refresh, or programmatic context updates. Use it to
|
|
39
|
+
* trigger side effects like navigation without fragile `useEffect`
|
|
40
|
+
* guards on the active org.
|
|
41
|
+
*/
|
|
42
|
+
readonly onOrgChanged?: (org: Organization) => void;
|
|
43
|
+
/** Additional CSS class names merged onto the trigger (or root in error/loading states). */
|
|
44
|
+
readonly className?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Organization switcher dropdown for sidebar navigation.
|
|
49
|
+
*
|
|
50
|
+
* Shows the active organization (name + slug), lists personal and team
|
|
51
|
+
* organizations grouped with icons, and provides a "Create organization"
|
|
52
|
+
* action that opens an inline dialog with {@link CreateOrganizationForm}.
|
|
53
|
+
*
|
|
54
|
+
* Designed for sidebar placement — the trigger uses `sidebar-*` design
|
|
55
|
+
* tokens. The portaled dropdown and dialog use standard `popover-*` /
|
|
56
|
+
* main-area tokens per theme-token-guidelines.
|
|
57
|
+
*
|
|
58
|
+
* Must be rendered inside an {@link OrgProvider}.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```tsx
|
|
62
|
+
* <OrgSwitcher onOrgChanged={(org) => navigate("/")} />
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export function OrgSwitcher({ onOrgChanged, className }: OrgSwitcherProps) {
|
|
66
|
+
const { orgs, activeOrg, setActiveOrg, isLoading, error, retry, refresh } =
|
|
67
|
+
useOrg();
|
|
68
|
+
const [createOpen, setCreateOpen] = useState(false);
|
|
69
|
+
|
|
70
|
+
const handleOrgSwitch = useCallback(
|
|
71
|
+
(slug: string) => {
|
|
72
|
+
const org = orgs.find((o) => o.metadata?.slug === slug);
|
|
73
|
+
if (org && org.metadata?.slug !== activeOrg?.metadata?.slug) {
|
|
74
|
+
setActiveOrg(org);
|
|
75
|
+
onOrgChanged?.(org);
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
[orgs, activeOrg, setActiveOrg, onOrgChanged],
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const handleCreated = useCallback(
|
|
82
|
+
(org: Organization) => {
|
|
83
|
+
setCreateOpen(false);
|
|
84
|
+
refresh(org.metadata?.slug);
|
|
85
|
+
onOrgChanged?.(org);
|
|
86
|
+
},
|
|
87
|
+
[refresh, onOrgChanged],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const personalOrgs = useMemo(
|
|
91
|
+
() => orgs.filter((o) => o.spec?.isPersonal),
|
|
92
|
+
[orgs],
|
|
93
|
+
);
|
|
94
|
+
const teamOrgs = useMemo(
|
|
95
|
+
() => orgs.filter((o) => !o.spec?.isPersonal),
|
|
96
|
+
[orgs],
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
if (isLoading) {
|
|
100
|
+
return <OrgSwitcherSkeleton className={className} />;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (error) {
|
|
104
|
+
return (
|
|
105
|
+
<div className={cn("flex items-center gap-2 px-2 py-1.5", className)}>
|
|
106
|
+
<AlertCircle className="text-destructive size-4 shrink-0" />
|
|
107
|
+
<span className="text-destructive truncate text-xs">{error}</span>
|
|
108
|
+
<button
|
|
109
|
+
onClick={retry}
|
|
110
|
+
className="text-sidebar-muted-foreground hover:text-sidebar-foreground shrink-0 rounded p-0.5 transition-colors"
|
|
111
|
+
aria-label="Retry loading organizations"
|
|
112
|
+
>
|
|
113
|
+
<RefreshCw className="size-3" />
|
|
114
|
+
</button>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const hasOrgs = orgs.length > 0 && activeOrg;
|
|
120
|
+
const TriggerIcon = activeOrg?.spec?.isPersonal ? User : Building2;
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<>
|
|
124
|
+
<Menu>
|
|
125
|
+
<MenuTrigger
|
|
126
|
+
aria-label="Organization menu"
|
|
127
|
+
className={cn(
|
|
128
|
+
"hover:bg-sidebar-accent flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-sm transition-colors focus:outline-none",
|
|
129
|
+
className,
|
|
130
|
+
)}
|
|
131
|
+
>
|
|
132
|
+
<TriggerIcon className="text-sidebar-muted-foreground mt-0.5 size-4 shrink-0 self-start" />
|
|
133
|
+
{hasOrgs ? (
|
|
134
|
+
<OrgLabel
|
|
135
|
+
org={activeOrg}
|
|
136
|
+
slugClassName="text-sidebar-muted-foreground"
|
|
137
|
+
/>
|
|
138
|
+
) : (
|
|
139
|
+
<span className="text-sidebar-muted-foreground truncate">
|
|
140
|
+
No organizations
|
|
141
|
+
</span>
|
|
142
|
+
)}
|
|
143
|
+
<ChevronsUpDown className="text-sidebar-muted-foreground ml-auto mt-0.5 size-3.5 shrink-0 self-start" />
|
|
144
|
+
</MenuTrigger>
|
|
145
|
+
|
|
146
|
+
<MenuContent align="start" side="bottom" sideOffset={4}>
|
|
147
|
+
{hasOrgs && (
|
|
148
|
+
<MenuRadioGroup
|
|
149
|
+
value={activeOrg.metadata?.slug ?? ""}
|
|
150
|
+
onValueChange={handleOrgSwitch}
|
|
151
|
+
>
|
|
152
|
+
{personalOrgs.map((org) => (
|
|
153
|
+
<MenuRadioItem
|
|
154
|
+
key={org.metadata?.slug}
|
|
155
|
+
value={org.metadata?.slug ?? ""}
|
|
156
|
+
className="items-start"
|
|
157
|
+
>
|
|
158
|
+
<User className="mt-0.5 size-3.5 shrink-0" />
|
|
159
|
+
<OrgLabel org={org} />
|
|
160
|
+
</MenuRadioItem>
|
|
161
|
+
))}
|
|
162
|
+
{personalOrgs.length > 0 && teamOrgs.length > 0 && (
|
|
163
|
+
<MenuSeparator />
|
|
164
|
+
)}
|
|
165
|
+
{teamOrgs.map((org) => (
|
|
166
|
+
<MenuRadioItem
|
|
167
|
+
key={org.metadata?.slug}
|
|
168
|
+
value={org.metadata?.slug ?? ""}
|
|
169
|
+
className="items-start"
|
|
170
|
+
>
|
|
171
|
+
<Building2 className="mt-0.5 size-3.5 shrink-0" />
|
|
172
|
+
<OrgLabel org={org} />
|
|
173
|
+
</MenuRadioItem>
|
|
174
|
+
))}
|
|
175
|
+
</MenuRadioGroup>
|
|
176
|
+
)}
|
|
177
|
+
|
|
178
|
+
{hasOrgs && <MenuSeparator />}
|
|
179
|
+
|
|
180
|
+
<MenuItem onClick={() => setCreateOpen(true)}>
|
|
181
|
+
<Plus className="size-4" />
|
|
182
|
+
Create organization
|
|
183
|
+
</MenuItem>
|
|
184
|
+
</MenuContent>
|
|
185
|
+
</Menu>
|
|
186
|
+
|
|
187
|
+
<DialogPrimitive.Root
|
|
188
|
+
open={createOpen}
|
|
189
|
+
onOpenChange={(open) => setCreateOpen(open)}
|
|
190
|
+
>
|
|
191
|
+
<DialogPrimitive.Portal>
|
|
192
|
+
<DialogPrimitive.Backdrop
|
|
193
|
+
className={cn(
|
|
194
|
+
"fixed inset-0 z-50 bg-black/50",
|
|
195
|
+
"data-open:animate-in data-open:fade-in-0",
|
|
196
|
+
"data-closed:animate-out data-closed:fade-out-0",
|
|
197
|
+
"duration-150",
|
|
198
|
+
)}
|
|
199
|
+
/>
|
|
200
|
+
{/* eslint-disable stigmer/no-main-tokens-in-sidebar -- The entire dialog is portaled outside the sidebar; main-area tokens are correct per DD-005. */}
|
|
201
|
+
<DialogPrimitive.Popup
|
|
202
|
+
className={cn(
|
|
203
|
+
"bg-background text-foreground ring-border/20",
|
|
204
|
+
"fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg p-6 shadow-lg ring-1 outline-none",
|
|
205
|
+
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
|
|
206
|
+
"data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
|
207
|
+
"duration-150",
|
|
208
|
+
)}
|
|
209
|
+
>
|
|
210
|
+
<DialogPrimitive.Title className="text-foreground text-sm font-semibold">
|
|
211
|
+
Create organization
|
|
212
|
+
</DialogPrimitive.Title>
|
|
213
|
+
<DialogPrimitive.Description className="text-muted-foreground mt-1 mb-4 text-xs">
|
|
214
|
+
Organizations are tenancy boundaries that own agents,
|
|
215
|
+
environments, and other resources.
|
|
216
|
+
</DialogPrimitive.Description>
|
|
217
|
+
<CreateOrganizationForm
|
|
218
|
+
onCreated={handleCreated}
|
|
219
|
+
onCancel={() => setCreateOpen(false)}
|
|
220
|
+
/>
|
|
221
|
+
</DialogPrimitive.Popup>
|
|
222
|
+
{/* eslint-enable stigmer/no-main-tokens-in-sidebar */}
|
|
223
|
+
</DialogPrimitive.Portal>
|
|
224
|
+
</DialogPrimitive.Root>
|
|
225
|
+
</>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// Internal sub-components
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Two-line org label: name (bold) + slug (muted). Used in the trigger
|
|
235
|
+
* (sidebar context → pass `slugClassName="text-sidebar-muted-foreground"`)
|
|
236
|
+
* and in dropdown radio items (popover context → default `text-muted-foreground`).
|
|
237
|
+
*/
|
|
238
|
+
function OrgLabel({
|
|
239
|
+
org,
|
|
240
|
+
slugClassName,
|
|
241
|
+
}: {
|
|
242
|
+
org: Organization;
|
|
243
|
+
slugClassName?: string;
|
|
244
|
+
}) {
|
|
245
|
+
const name = org.metadata?.name || org.metadata?.slug;
|
|
246
|
+
const slug = org.metadata?.slug;
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<span className="min-w-0 flex-1">
|
|
250
|
+
<span className="block truncate text-sm font-medium leading-tight">
|
|
251
|
+
{name}
|
|
252
|
+
</span>
|
|
253
|
+
{slug && (
|
|
254
|
+
<span
|
|
255
|
+
className={cn(
|
|
256
|
+
"block truncate text-xs leading-tight",
|
|
257
|
+
// eslint-disable-next-line stigmer/no-main-tokens-in-sidebar -- OrgLabel renders inside portaled dropdown content (popover context)
|
|
258
|
+
slugClassName ?? "text-muted-foreground",
|
|
259
|
+
)}
|
|
260
|
+
>
|
|
261
|
+
{slug}
|
|
262
|
+
</span>
|
|
263
|
+
)}
|
|
264
|
+
</span>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function OrgSwitcherSkeleton({ className }: { className?: string }) {
|
|
269
|
+
return (
|
|
270
|
+
<div className={cn("flex items-center gap-2 px-2 py-1.5", className)}>
|
|
271
|
+
<div className="bg-sidebar-muted size-4 animate-pulse rounded" />
|
|
272
|
+
<div className="bg-sidebar-muted h-4 w-24 animate-pulse rounded" />
|
|
273
|
+
</div>
|
|
274
|
+
);
|
|
275
|
+
}
|