@hanzo/iam 0.1.0 → 0.3.0

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/src/react.ts CHANGED
@@ -46,7 +46,7 @@ import type { ReactNode } from "react";
46
46
  import { BrowserIamSdk } from "./browser.js";
47
47
  import type { BrowserIamConfig } from "./browser.js";
48
48
  import { IamClient } from "./client.js";
49
- import type { IamUser, IamOrganization, TokenResponse } from "./types.js";
49
+ import type { IamUser, IamOrganization, IamProject, TokenResponse } from "./types.js";
50
50
 
51
51
  // ---------------------------------------------------------------------------
52
52
  // Types
@@ -96,6 +96,10 @@ export interface OrgState {
96
96
  currentOrgId: string | null;
97
97
  /** Switch to a different organization. */
98
98
  switchOrg: (orgId: string) => void;
99
+ /** All projects for the current organization. */
100
+ projects: IamProject[];
101
+ /** Currently selected project. */
102
+ currentProject: IamProject | null;
99
103
  /** Currently selected project ID within the org. */
100
104
  currentProjectId: string | null;
101
105
  /** Switch to a different project (null to clear). */
@@ -367,6 +371,7 @@ export function useIam(): IamContextValue {
367
371
  export function useOrganizations(): OrgState {
368
372
  const { config, isAuthenticated, accessToken } = useIam();
369
373
  const [organizations, setOrganizations] = useState<IamOrganization[]>([]);
374
+ const [projects, setProjects] = useState<IamProject[]>([]);
370
375
  const [isLoading, setIsLoading] = useState(false);
371
376
 
372
377
  const [currentOrgId, setCurrentOrgId] = useState<string | null>(() => {
@@ -391,6 +396,7 @@ export function useOrganizations(): OrgState {
391
396
  useEffect(() => {
392
397
  if (!isAuthenticated || !accessToken) {
393
398
  setOrganizations([]);
399
+ setProjects([]);
394
400
  return;
395
401
  }
396
402
 
@@ -459,14 +465,66 @@ export function useOrganizations(): OrgState {
459
465
  // eslint-disable-next-line react-hooks/exhaustive-deps
460
466
  }, [isAuthenticated, accessToken, config.serverUrl, config.clientId]);
461
467
 
468
+ // Fetch projects when currentOrgId changes
469
+ useEffect(() => {
470
+ if (!isAuthenticated || !accessToken || !currentOrgId) {
471
+ setProjects([]);
472
+ return;
473
+ }
474
+
475
+ let cancelled = false;
476
+
477
+ const fetchProjects = async () => {
478
+ try {
479
+ const client = new IamClient({
480
+ serverUrl: config.serverUrl,
481
+ clientId: config.clientId,
482
+ });
483
+ const orgProjects = await client.getOrganizationProjects(
484
+ currentOrgId,
485
+ accessToken,
486
+ );
487
+ if (!cancelled) {
488
+ setProjects(orgProjects);
489
+ // Auto-select default project if none selected
490
+ if (!currentProjectId && orgProjects.length > 0) {
491
+ const defaultProject =
492
+ orgProjects.find((p) => p.isDefault) ?? orgProjects[0];
493
+ setCurrentProjectId(defaultProject.name);
494
+ try {
495
+ localStorage.setItem(STORAGE_PROJECT_KEY, defaultProject.name);
496
+ } catch {
497
+ /* ok */
498
+ }
499
+ }
500
+ }
501
+ } catch {
502
+ // Projects API may not be available yet — that's ok
503
+ if (!cancelled) setProjects([]);
504
+ }
505
+ };
506
+
507
+ fetchProjects();
508
+ return () => {
509
+ cancelled = true;
510
+ };
511
+ // eslint-disable-next-line react-hooks/exhaustive-deps
512
+ }, [isAuthenticated, accessToken, currentOrgId, config.serverUrl, config.clientId]);
513
+
462
514
  const currentOrg = useMemo(
463
515
  () => organizations.find((o) => o.name === currentOrgId) ?? null,
464
516
  [organizations, currentOrgId],
465
517
  );
466
518
 
519
+ const currentProject = useMemo(
520
+ () => projects.find((p) => p.name === currentProjectId) ?? null,
521
+ [projects, currentProjectId],
522
+ );
523
+
467
524
  const switchOrg = useCallback((orgId: string) => {
468
525
  setCurrentOrgId(orgId);
469
526
  setCurrentProjectId(null);
527
+ setProjects([]);
470
528
  try {
471
529
  localStorage.setItem(STORAGE_ORG_KEY, orgId);
472
530
  localStorage.removeItem(STORAGE_PROJECT_KEY);
@@ -493,6 +551,8 @@ export function useOrganizations(): OrgState {
493
551
  currentOrg,
494
552
  currentOrgId,
495
553
  switchOrg,
554
+ projects,
555
+ currentProject,
496
556
  currentProjectId,
497
557
  switchProject,
498
558
  isLoading,
@@ -531,3 +591,129 @@ export function useIamToken(): {
531
591
 
532
592
  // Re-export context for advanced use
533
593
  export { IamContext };
594
+
595
+ // ---------------------------------------------------------------------------
596
+ // OrgProjectSwitcher component
597
+ // ---------------------------------------------------------------------------
598
+
599
+ export interface OrgProjectSwitcherProps {
600
+ organizations: Array<{ name: string; displayName?: string; owner?: string }>;
601
+ currentOrgId: string | null;
602
+ switchOrg: (orgId: string) => void;
603
+ projects?: Array<{ name: string; displayName?: string; organization?: string; isDefault?: boolean }>;
604
+ currentProjectId?: string | null;
605
+ switchProject?: (projectId: string | null) => void;
606
+ onTenantChange?: (orgId: string | null, projectId: string | null) => void;
607
+ environment?: string | null;
608
+ className?: string;
609
+ alwaysShow?: boolean;
610
+ }
611
+
612
+ /**
613
+ * Organization and project switcher component.
614
+ *
615
+ * @example
616
+ * ```tsx
617
+ * import { useOrganizations, OrgProjectSwitcher } from '@hanzo/iam/react'
618
+ *
619
+ * function Nav() {
620
+ * const orgState = useOrganizations()
621
+ * return <OrgProjectSwitcher {...orgState} />
622
+ * }
623
+ * ```
624
+ */
625
+ export function OrgProjectSwitcher({
626
+ organizations,
627
+ currentOrgId,
628
+ switchOrg,
629
+ projects = [],
630
+ currentProjectId = null,
631
+ switchProject,
632
+ onTenantChange,
633
+ environment,
634
+ className = "",
635
+ alwaysShow = false,
636
+ }: OrgProjectSwitcherProps) {
637
+ useEffect(() => {
638
+ onTenantChange?.(currentOrgId, currentProjectId ?? null);
639
+ }, [currentOrgId, currentProjectId, onTenantChange]);
640
+
641
+ const handleOrgChange = useCallback(
642
+ (e: { target: { value: string } }) => switchOrg(e.target.value),
643
+ [switchOrg],
644
+ );
645
+
646
+ const handleProjectChange = useCallback(
647
+ (e: { target: { value: string } }) => switchProject?.(e.target.value || null),
648
+ [switchProject],
649
+ );
650
+
651
+ if (!alwaysShow && organizations.length <= 1 && projects.length <= 1) {
652
+ if (organizations.length === 1) {
653
+ const org = organizations[0];
654
+ return createElement(
655
+ "div",
656
+ { className: `flex items-center gap-2 text-sm ${className}` },
657
+ createElement("span", { className: "font-medium" }, org.displayName || org.name),
658
+ projects.length === 1
659
+ ? [
660
+ createElement("span", { className: "text-muted-foreground", key: "sep" }, "/"),
661
+ createElement("span", { key: "proj" }, projects[0].displayName || projects[0].name),
662
+ ]
663
+ : null,
664
+ environment
665
+ ? createElement(
666
+ "span",
667
+ { className: "rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground" },
668
+ environment,
669
+ )
670
+ : null,
671
+ );
672
+ }
673
+ return null;
674
+ }
675
+
676
+ return createElement(
677
+ "div",
678
+ { className: `flex items-center gap-2 ${className}` },
679
+ createElement(
680
+ "select",
681
+ {
682
+ value: currentOrgId ?? "",
683
+ onChange: handleOrgChange,
684
+ className:
685
+ "h-8 rounded-md border border-border bg-background px-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring",
686
+ "aria-label": "Switch organization",
687
+ },
688
+ ...organizations.map((org) =>
689
+ createElement("option", { key: org.name, value: org.name }, org.displayName || org.name),
690
+ ),
691
+ ),
692
+ projects.length > 0 && switchProject
693
+ ? [
694
+ createElement("span", { className: "text-muted-foreground", key: "sep" }, "/"),
695
+ createElement(
696
+ "select",
697
+ {
698
+ key: "proj-select",
699
+ value: currentProjectId ?? "",
700
+ onChange: handleProjectChange,
701
+ className:
702
+ "h-8 rounded-md border border-border bg-background px-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring",
703
+ "aria-label": "Switch project",
704
+ },
705
+ ...projects.map((proj) =>
706
+ createElement("option", { key: proj.name, value: proj.name }, proj.displayName || proj.name),
707
+ ),
708
+ ),
709
+ ]
710
+ : null,
711
+ environment
712
+ ? createElement(
713
+ "span",
714
+ { className: "rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground" },
715
+ environment,
716
+ )
717
+ : null,
718
+ );
719
+ }
package/src/types.ts CHANGED
@@ -115,10 +115,10 @@ export type IamOrganization = {
115
115
  };
116
116
 
117
117
  // ---------------------------------------------------------------------------
118
- // Subscription / Plan / Pricing
118
+ // Billing (canonical types backed by Commerce service)
119
119
  // ---------------------------------------------------------------------------
120
120
 
121
- export type IamSubscription = {
121
+ export type Subscription = {
122
122
  owner: string;
123
123
  name: string;
124
124
  displayName?: string;
@@ -133,7 +133,7 @@ export type IamSubscription = {
133
133
  description?: string;
134
134
  };
135
135
 
136
- export type IamPlan = {
136
+ export type Plan = {
137
137
  owner: string;
138
138
  name: string;
139
139
  displayName?: string;
@@ -147,7 +147,7 @@ export type IamPlan = {
147
147
  role?: string;
148
148
  };
149
149
 
150
- export type IamPricing = {
150
+ export type Pricing = {
151
151
  owner: string;
152
152
  name: string;
153
153
  displayName?: string;
@@ -159,11 +159,7 @@ export type IamPricing = {
159
159
  trialDuration?: number;
160
160
  };
161
161
 
162
- // ---------------------------------------------------------------------------
163
- // Payment / Order
164
- // ---------------------------------------------------------------------------
165
-
166
- export type IamPayment = {
162
+ export type Payment = {
167
163
  owner: string;
168
164
  name: string;
169
165
  displayName?: string;
@@ -177,7 +173,7 @@ export type IamPayment = {
177
173
  message?: string;
178
174
  };
179
175
 
180
- export type IamOrder = {
176
+ export type Order = {
181
177
  owner: string;
182
178
  name: string;
183
179
  displayName?: string;
@@ -190,6 +186,62 @@ export type IamOrder = {
190
186
  message?: string;
191
187
  };
192
188
 
189
+ export type UsageRecord = {
190
+ owner: string;
191
+ name: string;
192
+ user?: string;
193
+ application?: string;
194
+ organization?: string;
195
+ project?: string;
196
+ model?: string;
197
+ provider?: string;
198
+ promptTokens?: number;
199
+ completionTokens?: number;
200
+ totalTokens?: number;
201
+ cost?: number;
202
+ currency?: string;
203
+ premium?: boolean;
204
+ stream?: boolean;
205
+ status?: string;
206
+ errorMsg?: string;
207
+ clientIp?: string;
208
+ requestId?: string;
209
+ createdTime?: string;
210
+ };
211
+
212
+ export type UsageSummary = {
213
+ totalRequests: number;
214
+ totalTokens: number;
215
+ totalCost: number;
216
+ promptTokens: number;
217
+ completionTokens: number;
218
+ };
219
+
220
+ // Backwards-compatible aliases
221
+ export type IamSubscription = Subscription;
222
+ export type IamPlan = Plan;
223
+ export type IamPricing = Pricing;
224
+ export type IamPayment = Payment;
225
+ export type IamOrder = Order;
226
+ export type IamUsageRecord = UsageRecord;
227
+ export type IamUsageSummary = UsageSummary;
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Project
231
+ // ---------------------------------------------------------------------------
232
+
233
+ export type IamProject = {
234
+ owner: string;
235
+ name: string;
236
+ displayName?: string;
237
+ description?: string;
238
+ organization: string;
239
+ tags?: string[];
240
+ metadata?: Record<string, unknown>;
241
+ isDefault?: boolean;
242
+ createdTime?: string;
243
+ };
244
+
193
245
  // ---------------------------------------------------------------------------
194
246
  // Auth result
195
247
  // ---------------------------------------------------------------------------