@luxfi/ui 7.3.2 → 7.4.2
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/dist/alert.cjs +2 -2
- package/dist/alert.js +1 -1
- package/dist/avatar.cjs +7 -6
- package/dist/avatar.js +7 -5
- package/dist/badge.cjs +36 -38
- package/dist/badge.js +31 -33
- package/dist/bank.cjs +13 -13
- package/dist/bank.d.cts +31 -24
- package/dist/bank.d.ts +31 -24
- package/dist/bank.js +1 -1
- package/dist/button.cjs +3 -3
- package/dist/button.js +1 -1
- package/dist/checkbox.cjs +4 -4
- package/dist/checkbox.d.cts +3 -2
- package/dist/checkbox.d.ts +3 -2
- package/dist/checkbox.js +5 -4
- package/dist/chrome.cjs +950 -0
- package/dist/chrome.d.cts +93 -0
- package/dist/chrome.d.ts +93 -0
- package/dist/chrome.js +921 -0
- package/dist/close-button.cjs +2 -2
- package/dist/close-button.js +1 -1
- package/dist/dialog.cjs +13 -14
- package/dist/dialog.d.cts +58 -5
- package/dist/dialog.d.ts +58 -5
- package/dist/dialog.js +1 -2
- package/dist/drawer.cjs +87 -21
- package/dist/drawer.js +85 -19
- package/dist/expiration-selector.cjs +3 -2
- package/dist/expiration-selector.js +3 -2
- package/dist/greeks-display.cjs +6 -6
- package/dist/greeks-display.js +6 -6
- package/dist/gui.cjs +13 -0
- package/dist/gui.d.cts +2 -0
- package/dist/gui.d.ts +2 -0
- package/dist/gui.js +2 -0
- package/dist/heading.cjs +4 -2
- package/dist/heading.js +4 -2
- package/dist/iam.cjs +206 -0
- package/dist/iam.d.cts +65 -0
- package/dist/iam.d.ts +65 -0
- package/dist/iam.js +201 -0
- package/dist/icon-button.cjs +2 -2
- package/dist/icon-button.js +1 -1
- package/dist/icons.cjs +13 -0
- package/dist/icons.d.cts +1 -0
- package/dist/icons.d.ts +1 -0
- package/dist/icons.js +2 -0
- package/dist/identity.cjs +420 -0
- package/dist/identity.d.cts +64 -0
- package/dist/identity.d.ts +64 -0
- package/dist/identity.js +398 -0
- package/dist/index.cjs +1699 -860
- package/dist/index.d.cts +12 -4
- package/dist/index.d.ts +12 -4
- package/dist/index.js +1618 -802
- package/dist/input.cjs +13 -3
- package/dist/input.d.cts +6 -0
- package/dist/input.d.ts +6 -0
- package/dist/input.js +13 -3
- package/dist/lux.config.cjs +77 -0
- package/dist/lux.config.d.cts +433 -0
- package/dist/lux.config.d.ts +433 -0
- package/dist/lux.config.js +69 -0
- package/dist/menu.cjs +4 -4
- package/dist/menu.js +4 -4
- package/dist/next.cjs +99 -0
- package/dist/next.d.cts +29 -0
- package/dist/next.d.ts +29 -0
- package/dist/next.js +96 -0
- package/dist/option-chain.cjs +1 -1
- package/dist/option-chain.js +1 -1
- package/dist/option-position.cjs +2 -1
- package/dist/option-position.js +2 -1
- package/dist/pnl-diagram.cjs +8 -6
- package/dist/pnl-diagram.js +8 -6
- package/dist/popover.cjs +8 -9
- package/dist/popover.js +1 -2
- package/dist/progress.cjs +7 -6
- package/dist/progress.js +7 -5
- package/dist/provider.cjs +199 -5
- package/dist/provider.d.cts +16 -3
- package/dist/provider.d.ts +16 -3
- package/dist/provider.js +195 -6
- package/dist/radio.cjs +9 -7
- package/dist/radio.js +10 -7
- package/dist/separator.cjs +3 -1
- package/dist/separator.js +3 -1
- package/dist/slider.cjs +28 -16
- package/dist/slider.js +28 -15
- package/dist/strategy-builder.cjs +2 -1
- package/dist/strategy-builder.js +2 -1
- package/dist/switch.cjs +11 -12
- package/dist/switch.js +12 -12
- package/dist/tag.cjs +38 -41
- package/dist/tag.js +31 -34
- package/dist/textarea.cjs +13 -3
- package/dist/textarea.d.cts +2 -0
- package/dist/textarea.d.ts +2 -0
- package/dist/textarea.js +13 -3
- package/dist/tooltip.cjs +31 -33
- package/dist/tooltip.js +25 -27
- package/dist/use-narrow.cjs +46 -0
- package/dist/use-narrow.d.cts +12 -0
- package/dist/use-narrow.d.ts +12 -0
- package/dist/use-narrow.js +20 -0
- package/dist/vite.cjs +40 -0
- package/dist/vite.d.cts +29 -0
- package/dist/vite.d.ts +29 -0
- package/dist/vite.js +38 -0
- package/dist/white-label.cjs +132 -0
- package/dist/white-label.d.cts +59 -0
- package/dist/white-label.d.ts +59 -0
- package/dist/white-label.js +124 -0
- package/package.json +95 -17
- package/src/avatar.tsx +22 -16
- package/src/bank.tsx +2 -2
- package/src/button.tsx +1 -1
- package/src/checkbox.tsx +15 -10
- package/src/chrome.tsx +459 -0
- package/src/close-button.tsx +1 -1
- package/src/dialog.tsx +1 -1
- package/src/drawer.tsx +83 -10
- package/src/expiration-selector.tsx +3 -2
- package/src/greeks-display.tsx +9 -6
- package/src/gui.ts +17 -0
- package/src/heading.tsx +12 -8
- package/src/iam.ts +170 -0
- package/src/icon-button.tsx +1 -1
- package/src/icons.ts +18 -0
- package/src/identity.tsx +318 -0
- package/src/index.ts +50 -1
- package/src/input.tsx +24 -4
- package/src/lux.config.ts +82 -0
- package/src/menu.tsx +4 -4
- package/src/next.ts +142 -0
- package/src/option-chain.tsx +1 -1
- package/src/option-position.tsx +2 -1
- package/src/pnl-diagram.tsx +11 -6
- package/src/popover.tsx +1 -1
- package/src/progress.tsx +15 -8
- package/src/provider.tsx +92 -12
- package/src/radio.tsx +15 -11
- package/src/separator.tsx +8 -3
- package/src/slider.tsx +35 -19
- package/src/strategy-builder.tsx +3 -2
- package/src/switch.tsx +17 -16
- package/src/textarea.tsx +18 -4
- package/src/tooltip.tsx +4 -23
- package/src/use-narrow.ts +40 -0
- package/src/vite.ts +78 -0
- package/src/white-label.ts +240 -0
- package/tokens.css +544 -319
- package/src/tokens.css +0 -438
package/src/heading.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { H1, H2, H3 } from '@hanzo/gui';
|
|
1
2
|
import React from 'react';
|
|
2
3
|
|
|
3
4
|
import { cn } from './utils';
|
|
@@ -9,7 +10,9 @@ export interface HeadingProps extends React.ComponentPropsWithoutRef<'h1'> {
|
|
|
9
10
|
size?: string;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
// gui's heading primitives — real h1/h2/h3 on web, sized Text on native, both
|
|
14
|
+
// inheriting the theme's heading font. The Lux type ramp stays in the classes.
|
|
15
|
+
const LEVEL_TAG = { '1': H1, '2': H2, '3': H3 } as const;
|
|
13
16
|
|
|
14
17
|
// base (mobile) + lg (desktop) responsive text styles per level
|
|
15
18
|
//
|
|
@@ -26,18 +29,19 @@ const BASE_CLASSES = 'font-heading text-heading';
|
|
|
26
29
|
|
|
27
30
|
export const Heading = React.forwardRef<HTMLHeadingElement, HeadingProps>(
|
|
28
31
|
function Heading({ level, className, mb, size: _size, style: styleProp, ...rest }, ref) {
|
|
29
|
-
const Tag = level ? LEVEL_TAG[level] :
|
|
32
|
+
const Tag = level ? LEVEL_TAG[level] : H2;
|
|
30
33
|
const levelClasses = level ? LEVEL_CLASSES[level] : undefined;
|
|
31
|
-
const mergedStyle = mb !== undefined
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
const mergedStyle = mb !== undefined ?
|
|
35
|
+
{ ...styleProp, marginBottom: typeof mb === 'number' ? `${ mb * 4 }px` : mb } :
|
|
36
|
+
styleProp;
|
|
34
37
|
|
|
35
38
|
return (
|
|
36
39
|
<Tag
|
|
37
|
-
ref={ ref }
|
|
40
|
+
ref={ ref as never }
|
|
41
|
+
unstyled
|
|
38
42
|
className={ cn(BASE_CLASSES, levelClasses, className) }
|
|
39
|
-
style={ mergedStyle }
|
|
40
|
-
{ ...rest }
|
|
43
|
+
style={ mergedStyle as never }
|
|
44
|
+
{ ...(rest as object) }
|
|
41
45
|
/>
|
|
42
46
|
);
|
|
43
47
|
},
|
package/src/iam.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Hanzo IAM read surface — the ONE identity source every Lux surface reads.
|
|
2
|
+
//
|
|
3
|
+
// Who am I, which orgs am I in, which projects does this org have. Every
|
|
4
|
+
// surface needs those three answers and every surface had grown its own way to
|
|
5
|
+
// get them (a hardcoded ORGS table here, a bespoke token store there, a
|
|
6
|
+
// per-app `/branding` map somewhere else). They are IAM's answers, so they are
|
|
7
|
+
// read from IAM.
|
|
8
|
+
//
|
|
9
|
+
// The endpoints are the Casdoor-compat verbs IAM opens to a registered browser
|
|
10
|
+
// origin (see hanzoai/iam internal/cors: get-account, get-organizations,
|
|
11
|
+
// get-organization, get-users, get-organization-projects). CORS decides which
|
|
12
|
+
// ORIGIN may read; the bearer decides WHO — a caller only ever sees what its
|
|
13
|
+
// own principal could already see, so there is nothing to gate here.
|
|
14
|
+
//
|
|
15
|
+
// VALUES ONLY: no React, no storage policy, no redirect. `fetch` is the single
|
|
16
|
+
// effect, and the base URL comes from the host-resolved white-label, so this
|
|
17
|
+
// module is identical on the server, in the browser and in a test.
|
|
18
|
+
|
|
19
|
+
import { resolveWhiteLabel, type WhiteLabel } from './white-label';
|
|
20
|
+
|
|
21
|
+
/** A signed-in principal, as IAM describes it. */
|
|
22
|
+
export interface IamAccount {
|
|
23
|
+
readonly owner: string;
|
|
24
|
+
readonly name: string;
|
|
25
|
+
readonly displayName?: string;
|
|
26
|
+
readonly email?: string;
|
|
27
|
+
readonly avatar?: string;
|
|
28
|
+
readonly isAdmin?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** An organization the principal can see. */
|
|
32
|
+
export interface IamOrg {
|
|
33
|
+
readonly owner: string;
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly displayName?: string;
|
|
36
|
+
readonly logo?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A project inside an organization (IAM's Org → Workspace → Project tier). */
|
|
40
|
+
export interface IamProject {
|
|
41
|
+
readonly owner: string;
|
|
42
|
+
readonly name: string;
|
|
43
|
+
readonly displayName?: string;
|
|
44
|
+
readonly organization?: string;
|
|
45
|
+
readonly workspace?: string;
|
|
46
|
+
readonly isDefault?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** IAM's response envelope. `data2` carries the total for paged reads. */
|
|
50
|
+
interface Envelope<T> {
|
|
51
|
+
readonly status?: string;
|
|
52
|
+
readonly msg?: string;
|
|
53
|
+
readonly data?: T;
|
|
54
|
+
readonly data2?: unknown;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A refusal from IAM, carrying enough to tell "not signed in" apart from
|
|
59
|
+
* "signed in, and the answer is genuinely empty" — the distinction a switcher
|
|
60
|
+
* has to render honestly.
|
|
61
|
+
*/
|
|
62
|
+
export class IamError extends Error {
|
|
63
|
+
readonly status: number;
|
|
64
|
+
readonly unauthenticated: boolean;
|
|
65
|
+
constructor(message: string, status: number, unauthenticated: boolean) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.name = 'IamError';
|
|
68
|
+
this.status = status;
|
|
69
|
+
this.unauthenticated = unauthenticated;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** IAM answers 401, or 200 with a sign-in message. Both mean "no session". */
|
|
74
|
+
const SIGNED_OUT = /please sign in|authentication required|unauthorized/i;
|
|
75
|
+
|
|
76
|
+
function isSignedOut(status: number, msg: string): boolean {
|
|
77
|
+
return status === 401 || status === 403 || SIGNED_OUT.test(msg);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The IAM API root for a host — `lux.cloud` → `https://lux.id/v1/iam`. */
|
|
81
|
+
export function iamBase(host?: string | null | WhiteLabel): string {
|
|
82
|
+
const wl = typeof host === 'object' && host !== null ? host : resolveWhiteLabel(host as string | null | undefined);
|
|
83
|
+
return `${ wl.issuer }/v1/iam`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** How a read reaches IAM: where, and with whose bearer. */
|
|
87
|
+
export interface IamCall {
|
|
88
|
+
readonly base: string;
|
|
89
|
+
readonly token?: string | null;
|
|
90
|
+
readonly signal?: AbortSignal;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function read<T>(call: IamCall, verb: string, params: Record<string, string | number>): Promise<T> {
|
|
94
|
+
const url = new URL(`${ call.base }/${ verb }`);
|
|
95
|
+
for (const [ k, v ] of Object.entries(params)) url.searchParams.set(k, String(v));
|
|
96
|
+
|
|
97
|
+
let res: Response;
|
|
98
|
+
try {
|
|
99
|
+
res = await fetch(url.toString(), {
|
|
100
|
+
method: 'GET',
|
|
101
|
+
// The bearer is the whole credential. IAM does not allow credentialed
|
|
102
|
+
// CORS, so sending cookies would only get the response blocked.
|
|
103
|
+
headers: call.token ? { Authorization: `Bearer ${ call.token }` } : {},
|
|
104
|
+
signal: call.signal,
|
|
105
|
+
});
|
|
106
|
+
} catch (e) {
|
|
107
|
+
// A network/CORS failure is NOT "no orgs" — say so, so the caller can show
|
|
108
|
+
// an error instead of an empty switcher.
|
|
109
|
+
throw new IamError(e instanceof Error ? e.message : 'IAM unreachable', 0, false);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const body = (await res.json().catch(() => ({}))) as Envelope<T>;
|
|
113
|
+
const msg = typeof body.msg === 'string' ? body.msg : '';
|
|
114
|
+
if (!res.ok || body.status === 'error') {
|
|
115
|
+
throw new IamError(msg || `IAM ${ verb } failed (${ res.status })`, res.status, isSignedOut(res.status, msg));
|
|
116
|
+
}
|
|
117
|
+
return body.data as T;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Rows per page for the org list — small first paint, more on demand. */
|
|
121
|
+
export const IAM_PAGE_SIZE = 20;
|
|
122
|
+
|
|
123
|
+
export const iam = {
|
|
124
|
+
/** The signed-in principal, or null when there is no session. */
|
|
125
|
+
async account(call: IamCall): Promise<IamAccount | null> {
|
|
126
|
+
try {
|
|
127
|
+
const data = await read<IamAccount | null>(call, 'get-account', {});
|
|
128
|
+
return data && data.name ? data : null;
|
|
129
|
+
} catch (e) {
|
|
130
|
+
if (e instanceof IamError && e.unauthenticated) return null;
|
|
131
|
+
throw e;
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* One page of the orgs the principal can see. IAM scopes the read to the
|
|
137
|
+
* caller's authority, so a normal member gets their own org and a super
|
|
138
|
+
* admin gets the tenant list — the page never has to decide.
|
|
139
|
+
*/
|
|
140
|
+
async organizations(call: IamCall, page = 0, query = '', pageSize = IAM_PAGE_SIZE): Promise<Array<IamOrg>> {
|
|
141
|
+
const params: Record<string, string | number> = {
|
|
142
|
+
owner: 'admin', // IAM orgs live under the reserved `admin` owner
|
|
143
|
+
p: page + 1, // the backend page is 1-based
|
|
144
|
+
pageSize,
|
|
145
|
+
sortField: 'name',
|
|
146
|
+
sortOrder: 'ascending',
|
|
147
|
+
};
|
|
148
|
+
const q = query.trim();
|
|
149
|
+
if (q) {
|
|
150
|
+
params.field = 'name';
|
|
151
|
+
params.value = q;
|
|
152
|
+
}
|
|
153
|
+
return (await read<Array<IamOrg> | null>(call, 'get-organizations', params)) ?? [];
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
/** One organization by name. */
|
|
157
|
+
async organization(call: IamCall, name: string): Promise<IamOrg | null> {
|
|
158
|
+
return (await read<IamOrg | null>(call, 'get-organization', { id: `admin/${ name }` })) ?? null;
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
/** Members of an organization. */
|
|
162
|
+
async users(call: IamCall, org: string): Promise<Array<IamAccount>> {
|
|
163
|
+
return (await read<Array<IamAccount> | null>(call, 'get-users', { owner: org })) ?? [];
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
/** The organization's projects — IAM keys this read by `organization`. */
|
|
167
|
+
async projects(call: IamCall, org: string): Promise<Array<IamProject>> {
|
|
168
|
+
return (await read<Array<IamProject> | null>(call, 'get-organization-projects', { organization: org })) ?? [];
|
|
169
|
+
},
|
|
170
|
+
};
|
package/src/icon-button.tsx
CHANGED
package/src/icons.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The Lux icon set — one set, one import path.
|
|
2
|
+
//
|
|
3
|
+
// Lucide, exactly as @hanzo/gui standardises on it. @hanzo/gui ships the same
|
|
4
|
+
// drawings twice, once per runtime — `@hanzogui/lucide-icons-2` for React
|
|
5
|
+
// Native, `lucide-react` for the DOM — and the geometry is identical
|
|
6
|
+
// path-for-path, so this is one set and not two. Lux surfaces are DOM, so the
|
|
7
|
+
// DOM delivery is what `@luxfi/ui/icons` hands them.
|
|
8
|
+
//
|
|
9
|
+
// import { Box, Zap, Info } from '@luxfi/ui/icons';
|
|
10
|
+
//
|
|
11
|
+
// The theming is the set's own contract, and it is why nothing here wraps it:
|
|
12
|
+
// every Lucide glyph is a 24x24, 2px, round-cap outline stroked in
|
|
13
|
+
// `currentColor`, so it takes the colour of the text it sits next to and stays
|
|
14
|
+
// monochrome by construction. Size it with `className="size-4"`, colour it by
|
|
15
|
+
// colouring its container — never by reaching for a different drawing.
|
|
16
|
+
//
|
|
17
|
+
// A second icon package inside a Lux surface is a bug, not a preference.
|
|
18
|
+
export * from 'lucide-react';
|
package/src/identity.tsx
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
iam,
|
|
7
|
+
iamBase,
|
|
8
|
+
IamError,
|
|
9
|
+
type IamAccount,
|
|
10
|
+
type IamOrg,
|
|
11
|
+
type IamProject,
|
|
12
|
+
} from './iam';
|
|
13
|
+
import { useWhiteLabel } from './provider';
|
|
14
|
+
import type { WhiteLabel } from './white-label';
|
|
15
|
+
|
|
16
|
+
// Who is signed in, and what are they acting as.
|
|
17
|
+
//
|
|
18
|
+
// One context, read by every piece of chrome. Identity and org membership come
|
|
19
|
+
// from IAM (see ./iam); brand, theme, issuer and IAM client come from the host
|
|
20
|
+
// (see ./white-label). Nothing here is configured per deployment.
|
|
21
|
+
//
|
|
22
|
+
// The state is FOUR values, not a boolean, because a switcher renders each of
|
|
23
|
+
// them differently and conflating them is how an app ends up showing an empty
|
|
24
|
+
// account menu to a signed-out visitor:
|
|
25
|
+
//
|
|
26
|
+
// loading — the answer is on its way
|
|
27
|
+
// anonymous — nobody is signed in; offer sign-in, never an empty list
|
|
28
|
+
// ready — signed in; the lists are the real lists (possibly empty)
|
|
29
|
+
// error — IAM could not be read; say so, never render "no organizations"
|
|
30
|
+
|
|
31
|
+
export type IdentityStatus = 'loading' | 'anonymous' | 'ready' | 'error';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How the chrome gets a bearer and moves the user in and out of a session.
|
|
35
|
+
*
|
|
36
|
+
* Credentials are `@hanzo/iam`'s job, not the design system's — so this is the
|
|
37
|
+
* seam, and a surface using the SDK supplies only the two verbs:
|
|
38
|
+
*
|
|
39
|
+
* auth={{ signIn: () => void startLogin(), signOut: () => void logout() }}
|
|
40
|
+
*
|
|
41
|
+
* The bearer itself needs no wiring: the default reads the SDK's own token
|
|
42
|
+
* store, so there is exactly one token and one refresh timer per surface.
|
|
43
|
+
*/
|
|
44
|
+
export interface IdentityAuth {
|
|
45
|
+
/** The IAM access token. Defaults to the `@hanzo/iam` SDK's token store. */
|
|
46
|
+
readonly token?: () => string | null | Promise<string | null>;
|
|
47
|
+
/** Start a sign-in. */
|
|
48
|
+
readonly signIn?: () => void;
|
|
49
|
+
/** End the session. */
|
|
50
|
+
readonly signOut?: () => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface Identity {
|
|
54
|
+
readonly status: IdentityStatus;
|
|
55
|
+
/** The signed-in principal, or null. */
|
|
56
|
+
readonly account: IamAccount | null;
|
|
57
|
+
/** Organizations the principal can act in. */
|
|
58
|
+
readonly orgs: ReadonlyArray<IamOrg>;
|
|
59
|
+
/** The organization the surface is currently scoped to, or null. */
|
|
60
|
+
readonly org: IamOrg | null;
|
|
61
|
+
/** Projects in the current organization. */
|
|
62
|
+
readonly projects: ReadonlyArray<IamProject>;
|
|
63
|
+
/** The project the surface is currently scoped to, or null. */
|
|
64
|
+
readonly project: IamProject | null;
|
|
65
|
+
/** Why the reads failed, when status is 'error'. */
|
|
66
|
+
readonly error: string | null;
|
|
67
|
+
/** Host-resolved brand, theme, issuer and IAM client. */
|
|
68
|
+
readonly whiteLabel: WhiteLabel;
|
|
69
|
+
readonly setOrg: (name: string) => void;
|
|
70
|
+
readonly setProject: (name: string) => void;
|
|
71
|
+
readonly signIn: () => void;
|
|
72
|
+
readonly signOut: () => void;
|
|
73
|
+
readonly reload: () => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- The default auth: the @hanzo/iam SDK's own token store -----------------
|
|
77
|
+
// `@hanzo/iam/browser` keeps its PKCE tokens under `hanzo_iam_*`. Reading that
|
|
78
|
+
// key IS the integration — no second SDK instance, no duplicated refresh
|
|
79
|
+
// timer, and a surface already using @hanzo/iam needs no wiring at all.
|
|
80
|
+
const IAM_TOKEN_KEY = 'hanzo_iam_access_token';
|
|
81
|
+
|
|
82
|
+
function storedToken(): string | null {
|
|
83
|
+
if (typeof window === 'undefined') return null;
|
|
84
|
+
for (const store of [ window.sessionStorage, window.localStorage ]) {
|
|
85
|
+
try {
|
|
86
|
+
const t = store.getItem(IAM_TOKEN_KEY);
|
|
87
|
+
if (t) return t;
|
|
88
|
+
} catch {
|
|
89
|
+
// Storage disabled (private mode / sandboxed frame) — treat as signed out.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function clearStoredTokens(): void {
|
|
96
|
+
if (typeof window === 'undefined') return;
|
|
97
|
+
for (const store of [ window.sessionStorage, window.localStorage ]) {
|
|
98
|
+
try {
|
|
99
|
+
for (const k of [ 'access_token', 'refresh_token', 'id_token', 'expires_at' ]) {
|
|
100
|
+
store.removeItem(`hanzo_iam_${ k }`);
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// Nothing to clear.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const defaultAuth: IdentityAuth = {};
|
|
109
|
+
|
|
110
|
+
// --- Scope persistence ------------------------------------------------------
|
|
111
|
+
// Which org/project the user last acted in, per host, so a reload does not
|
|
112
|
+
// silently move them somewhere else.
|
|
113
|
+
const scopeKey = (host: string, kind: string) => `lux-ui:${ kind }:${ host }`;
|
|
114
|
+
|
|
115
|
+
function readScope(host: string, kind: string): string | null {
|
|
116
|
+
if (typeof window === 'undefined') return null;
|
|
117
|
+
try {
|
|
118
|
+
return window.localStorage.getItem(scopeKey(host, kind));
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function writeScope(host: string, kind: string, value: string): void {
|
|
125
|
+
if (typeof window === 'undefined') return;
|
|
126
|
+
try {
|
|
127
|
+
window.localStorage.setItem(scopeKey(host, kind), value);
|
|
128
|
+
} catch {
|
|
129
|
+
// Selection stays live in-session; it just will not survive a reload.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const IdentityContext = React.createContext<Identity | null>(null);
|
|
134
|
+
|
|
135
|
+
export interface IdentityProviderProps {
|
|
136
|
+
readonly children: React.ReactNode;
|
|
137
|
+
/** Bearer + sign-in/out. Defaults to the `@hanzo/iam` token store. */
|
|
138
|
+
readonly auth?: IdentityAuth;
|
|
139
|
+
/** Where sign-in goes when `auth.signIn` is not supplied. */
|
|
140
|
+
readonly loginPath?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const IdentityProvider = ({
|
|
144
|
+
children,
|
|
145
|
+
auth = defaultAuth,
|
|
146
|
+
loginPath = '/login',
|
|
147
|
+
}: IdentityProviderProps): React.ReactElement => {
|
|
148
|
+
const whiteLabel = useWhiteLabel();
|
|
149
|
+
const base = React.useMemo(() => iamBase(whiteLabel), [ whiteLabel ]);
|
|
150
|
+
|
|
151
|
+
const [ status, setStatus ] = React.useState<IdentityStatus>('loading');
|
|
152
|
+
const [ account, setAccount ] = React.useState<IamAccount | null>(null);
|
|
153
|
+
const [ orgs, setOrgs ] = React.useState<ReadonlyArray<IamOrg>>([]);
|
|
154
|
+
const [ projects, setProjects ] = React.useState<ReadonlyArray<IamProject>>([]);
|
|
155
|
+
const [ error, setError ] = React.useState<string | null>(null);
|
|
156
|
+
const [ orgName, setOrgName ] = React.useState<string | null>(null);
|
|
157
|
+
const [ projectName, setProjectName ] = React.useState<string | null>(null);
|
|
158
|
+
const [ epoch, setEpoch ] = React.useState(0);
|
|
159
|
+
|
|
160
|
+
// Identity + org membership.
|
|
161
|
+
React.useEffect(() => {
|
|
162
|
+
const ac = new AbortController();
|
|
163
|
+
let live = true;
|
|
164
|
+
void (async () => {
|
|
165
|
+
const token = await (auth.token ?? storedToken)();
|
|
166
|
+
if (!live) return;
|
|
167
|
+
if (!token) {
|
|
168
|
+
setStatus('anonymous');
|
|
169
|
+
setAccount(null);
|
|
170
|
+
setOrgs([]);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const call = { base, token, signal: ac.signal };
|
|
174
|
+
try {
|
|
175
|
+
const me = await iam.account(call);
|
|
176
|
+
if (!live) return;
|
|
177
|
+
if (!me) {
|
|
178
|
+
setStatus('anonymous');
|
|
179
|
+
setAccount(null);
|
|
180
|
+
setOrgs([]);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
setAccount(me);
|
|
184
|
+
// A member who cannot list the tenant table still has their OWN org:
|
|
185
|
+
// fall back to the one the account names rather than showing nothing.
|
|
186
|
+
const rows = await iam.organizations(call).catch((e: unknown) => {
|
|
187
|
+
if (e instanceof IamError && !e.unauthenticated) return [ { owner: 'admin', name: me.owner } ];
|
|
188
|
+
throw e;
|
|
189
|
+
});
|
|
190
|
+
if (!live) return;
|
|
191
|
+
setOrgs(rows);
|
|
192
|
+
setStatus('ready');
|
|
193
|
+
setError(null);
|
|
194
|
+
} catch (e) {
|
|
195
|
+
if (!live || ac.signal.aborted) return;
|
|
196
|
+
if (e instanceof IamError && e.unauthenticated) {
|
|
197
|
+
setStatus('anonymous');
|
|
198
|
+
setAccount(null);
|
|
199
|
+
setOrgs([]);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
setStatus('error');
|
|
203
|
+
setError(e instanceof Error ? e.message : 'Could not reach IAM.');
|
|
204
|
+
}
|
|
205
|
+
})();
|
|
206
|
+
return () => {
|
|
207
|
+
live = false;
|
|
208
|
+
ac.abort();
|
|
209
|
+
};
|
|
210
|
+
}, [ base, auth, epoch ]);
|
|
211
|
+
|
|
212
|
+
// The org the surface is scoped to: the persisted choice when it is still
|
|
213
|
+
// one the user has, else the account's own org, else the first row.
|
|
214
|
+
const org = React.useMemo<IamOrg | null>(() => {
|
|
215
|
+
if (orgs.length === 0) return null;
|
|
216
|
+
const saved = orgName ?? readScope(whiteLabel.host, 'org');
|
|
217
|
+
return (
|
|
218
|
+
orgs.find((o) => o.name === saved) ??
|
|
219
|
+
orgs.find((o) => o.name === account?.owner) ??
|
|
220
|
+
orgs[0]
|
|
221
|
+
);
|
|
222
|
+
}, [ orgs, orgName, account, whiteLabel.host ]);
|
|
223
|
+
|
|
224
|
+
// Projects follow the org. A project read is allowed to fail without
|
|
225
|
+
// downgrading the whole identity — the org switcher still works.
|
|
226
|
+
React.useEffect(() => {
|
|
227
|
+
if (!org) {
|
|
228
|
+
setProjects([]);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const ac = new AbortController();
|
|
232
|
+
let live = true;
|
|
233
|
+
void (async () => {
|
|
234
|
+
const token = await (auth.token ?? storedToken)();
|
|
235
|
+
if (!live || !token) return;
|
|
236
|
+
const rows = await iam
|
|
237
|
+
.projects({ base, token, signal: ac.signal }, org.name)
|
|
238
|
+
.catch(() => [] as Array<IamProject>);
|
|
239
|
+
if (live) setProjects(rows);
|
|
240
|
+
})();
|
|
241
|
+
return () => {
|
|
242
|
+
live = false;
|
|
243
|
+
ac.abort();
|
|
244
|
+
};
|
|
245
|
+
}, [ base, auth, org, epoch ]);
|
|
246
|
+
|
|
247
|
+
const project = React.useMemo<IamProject | null>(() => {
|
|
248
|
+
if (projects.length === 0) return null;
|
|
249
|
+
const saved = projectName ?? readScope(whiteLabel.host, 'project');
|
|
250
|
+
return (
|
|
251
|
+
projects.find((p) => p.name === saved) ??
|
|
252
|
+
projects.find((p) => p.isDefault) ??
|
|
253
|
+
projects[0]
|
|
254
|
+
);
|
|
255
|
+
}, [ projects, projectName, whiteLabel.host ]);
|
|
256
|
+
|
|
257
|
+
const value = React.useMemo<Identity>(() => ({
|
|
258
|
+
status,
|
|
259
|
+
account,
|
|
260
|
+
orgs,
|
|
261
|
+
org,
|
|
262
|
+
projects,
|
|
263
|
+
project,
|
|
264
|
+
error,
|
|
265
|
+
whiteLabel,
|
|
266
|
+
setOrg: (name: string) => {
|
|
267
|
+
writeScope(whiteLabel.host, 'org', name);
|
|
268
|
+
setOrgName(name);
|
|
269
|
+
setProjectName(null);
|
|
270
|
+
},
|
|
271
|
+
setProject: (name: string) => {
|
|
272
|
+
writeScope(whiteLabel.host, 'project', name);
|
|
273
|
+
setProjectName(name);
|
|
274
|
+
},
|
|
275
|
+
signIn: () => {
|
|
276
|
+
if (auth.signIn) return auth.signIn();
|
|
277
|
+
if (typeof window !== 'undefined') window.location.assign(loginPath);
|
|
278
|
+
},
|
|
279
|
+
signOut: () => {
|
|
280
|
+
if (auth.signOut) return auth.signOut();
|
|
281
|
+
clearStoredTokens();
|
|
282
|
+
if (typeof window !== 'undefined') window.location.assign(loginPath);
|
|
283
|
+
},
|
|
284
|
+
reload: () => setEpoch((n) => n + 1),
|
|
285
|
+
}), [ status, account, orgs, org, projects, project, error, whiteLabel, auth, loginPath ]);
|
|
286
|
+
|
|
287
|
+
return <IdentityContext.Provider value={ value }>{ children }</IdentityContext.Provider>;
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The current identity.
|
|
292
|
+
*
|
|
293
|
+
* Outside an `<IdentityProvider>` this reports `anonymous` rather than
|
|
294
|
+
* throwing: chrome that renders before a surface has wired identity should
|
|
295
|
+
* offer sign-in, not crash the page.
|
|
296
|
+
*/
|
|
297
|
+
export function useIdentity(): Identity {
|
|
298
|
+
const ctx = React.useContext(IdentityContext);
|
|
299
|
+
const whiteLabel = useWhiteLabel();
|
|
300
|
+
const fallback = React.useMemo<Identity>(() => ({
|
|
301
|
+
status: 'anonymous',
|
|
302
|
+
account: null,
|
|
303
|
+
orgs: [],
|
|
304
|
+
org: null,
|
|
305
|
+
projects: [],
|
|
306
|
+
project: null,
|
|
307
|
+
error: null,
|
|
308
|
+
whiteLabel,
|
|
309
|
+
setOrg: () => undefined,
|
|
310
|
+
setProject: () => undefined,
|
|
311
|
+
signIn: () => {
|
|
312
|
+
if (typeof window !== 'undefined') window.location.assign('/login');
|
|
313
|
+
},
|
|
314
|
+
signOut: () => undefined,
|
|
315
|
+
reload: () => undefined,
|
|
316
|
+
}), [ whiteLabel ]);
|
|
317
|
+
return ctx ?? fallback;
|
|
318
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
1
|
-
export { AppProvider } from './provider';
|
|
1
|
+
export { AppProvider, useWhiteLabel } from './provider';
|
|
2
2
|
export { cn } from './utils';
|
|
3
|
+
export { useBelow, useIsNarrow, useIsTouch, NARROW_PX, TOUCH_PX } from './use-narrow';
|
|
4
|
+
|
|
5
|
+
// Identity = IAM, never a per-app copy. `useIdentity()` is the ONE answer to
|
|
6
|
+
// "who is signed in, which orgs, which projects", and the chrome below renders
|
|
7
|
+
// it. See ./iam for the endpoints and ./identity for the four honest states.
|
|
8
|
+
export { iam, iamBase, IamError, IAM_PAGE_SIZE } from './iam';
|
|
9
|
+
export type { IamAccount, IamOrg, IamProject, IamCall } from './iam';
|
|
10
|
+
export { IdentityProvider, useIdentity } from './identity';
|
|
11
|
+
export type { Identity, IdentityAuth, IdentityStatus, IdentityProviderProps } from './identity';
|
|
12
|
+
|
|
13
|
+
// The application chrome — ONE user switcher, ONE org switcher, ONE project
|
|
14
|
+
// switcher, ONE settings menu, ONE nav. All four switchers are the same
|
|
15
|
+
// `Switcher` mechanism over different values.
|
|
16
|
+
export {
|
|
17
|
+
Switcher,
|
|
18
|
+
OrgSwitcher,
|
|
19
|
+
ProjectSwitcher,
|
|
20
|
+
UserMenu,
|
|
21
|
+
SettingsMenu,
|
|
22
|
+
AppNav,
|
|
23
|
+
initials,
|
|
24
|
+
} from './chrome';
|
|
25
|
+
export type {
|
|
26
|
+
ChromeItem,
|
|
27
|
+
SwitcherProps,
|
|
28
|
+
UserMenuProps,
|
|
29
|
+
SettingsMenuProps,
|
|
30
|
+
NavLink,
|
|
31
|
+
AppNavProps,
|
|
32
|
+
} from './chrome';
|
|
33
|
+
|
|
34
|
+
// White-label = a function of the Host header, never of the deployment. Brand,
|
|
35
|
+
// gui theme row, OIDC issuer and IAM client id all come from here.
|
|
36
|
+
// (The engine itself is at '@luxfi/ui/gui'; it is a separate entry because the
|
|
37
|
+
// skin and the engine both define Button, Input, Switch, Select and friends.)
|
|
38
|
+
export {
|
|
39
|
+
resolveWhiteLabel,
|
|
40
|
+
currentHost,
|
|
41
|
+
orgIdentity,
|
|
42
|
+
orgs,
|
|
43
|
+
DEFAULT_ORG,
|
|
44
|
+
} from './white-label';
|
|
45
|
+
export type { Org, OrgIdentity, WhiteLabel } from './white-label';
|
|
46
|
+
|
|
47
|
+
// Lux gui config — the createGui() result (fed to GuiProvider) + the brand
|
|
48
|
+
// child-theme rows. Exposed so app bundlers (Vite/Next SSR extraction) and
|
|
49
|
+
// escape-hatch consumers can reach the resolved config and brand palette.
|
|
50
|
+
export { default as guiConfig, config, luxThemes, LUX_BRAND, PARS_GOLD } from './lux.config';
|
|
51
|
+
export type { Conf, LuxBrand } from './lux.config';
|
|
3
52
|
|
|
4
53
|
export {
|
|
5
54
|
AccordionRoot,
|
package/src/input.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Input as GuiInput } from '@hanzo/gui';
|
|
1
2
|
import React from 'react';
|
|
2
3
|
|
|
3
4
|
import { cn } from './utils';
|
|
@@ -34,15 +35,34 @@ type InputVariant = 'outline' | 'filled' | 'unstyled';
|
|
|
34
35
|
export interface InputProps extends Omit<React.ComponentPropsWithRef<'input'>, 'size'> {
|
|
35
36
|
readonly size?: InputSize;
|
|
36
37
|
readonly variant?: InputVariant;
|
|
38
|
+
/**
|
|
39
|
+
* React-Native convention: called with the text value on every change. The gui
|
|
40
|
+
* Input raises it natively; on web we bridge it off `onChange`, so callers
|
|
41
|
+
* using EITHER API get updates on either platform.
|
|
42
|
+
*/
|
|
43
|
+
readonly onChangeText?: (text: string) => void;
|
|
37
44
|
}
|
|
38
45
|
|
|
46
|
+
// gui's Input — a real `<input>` on web, a real TextInput on native (soft
|
|
47
|
+
// keyboard, `returnKeyType`, autofill, IME) — wearing the Lux size/variant
|
|
48
|
+
// classes. `size` stays the Lux scale and never reaches the primitive, whose
|
|
49
|
+
// `size` is a gui SizeToken.
|
|
39
50
|
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
40
|
-
({ size = 'md', variant = 'outline', className, ...rest }, ref) => {
|
|
51
|
+
({ size = 'md', variant = 'outline', className, onChange, onChangeText, ...rest }, ref) => {
|
|
52
|
+
const handleChange = React.useCallback(
|
|
53
|
+
(event: React.ChangeEvent<HTMLInputElement>): void => {
|
|
54
|
+
onChange?.(event);
|
|
55
|
+
onChangeText?.(event.target.value);
|
|
56
|
+
},
|
|
57
|
+
[ onChange, onChangeText ],
|
|
58
|
+
);
|
|
41
59
|
return (
|
|
42
|
-
<
|
|
43
|
-
ref={ ref }
|
|
60
|
+
<GuiInput
|
|
61
|
+
ref={ ref as never }
|
|
62
|
+
unstyled
|
|
44
63
|
className={ cn(INPUT_BASE, INPUT_SIZE_CLASSES[size], INPUT_VARIANT_CLASSES[variant], className) }
|
|
45
|
-
{ ...rest }
|
|
64
|
+
{ ...(rest as object) }
|
|
65
|
+
onChange={ handleChange as never }
|
|
46
66
|
/>
|
|
47
67
|
);
|
|
48
68
|
},
|