@4cloudguru/cloud-suite-ui
v0.13.1
Published
Shared UI foundation (theme, identity, app shell, and cross-cutting components) for the Terraform suite frontends.
Readme
@4cloudguru/cloud-suite-ui
Shared UI foundation for the Terraform suite frontends (terraform-registry-frontend and terraform-state-manager-frontend). It centralises the look-and-feel and cross-cutting behaviour so both apps stay in visual and behavioural parity from a single source of truth.
What's inside
| Area | Exports |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Tokens | BRAND_PRIMARY, SECONDARY_LIGHT, SECONDARY_DARK, dark surfaces, font stack, BORDER_RADIUS, RTL_LANGUAGES |
| Theme | createAppTheme(mode, prefersReducedMotion, direction, overrides), SuiteThemeProvider, useThemeMode |
| Identity | AuthProvider (parameterised by an AuthApi), useAuth (returns hasScope, memberships, organizationChoices, currentOrganizationId, setCurrentOrganization), ADMIN_SCOPE, SESSION_WARNING_LEAD_MS, SessionExpiryWarning, ORGANIZATION_HEADER, DEFAULT_ORGANIZATION_KEY, resolveCurrentOrganization, shouldOfferOrganizationChoice, actingOrganizationChoices, types |
| Consent | ConsentProvider, useConsent, ConsentBanner |
| Components | PageHeader, DashboardCard, Page, NotificationChannelsSection, ApiKeyExpirySettingsCard, BrandingSettingsCard (requires a host-supplied validators prop) |
| Shell | SuiteLayout (parameterised by nav + branding + auth), SuiteSwitcher, OrganizationPicker, nav types |
| Utils | isSafeUrl (host-supplied URL guard for navigation / image sinks) |
Acting organization
A user who belongs to more than one organization has to say which one a write belongs to.
AuthProvider resolves that from their memberships plus a remembered choice, and exposes it as
currentOrganizationId; OrganizationPicker renders the choice when there is one.
Three properties are worth knowing before wiring it:
- A single-organization deployment is unchanged. With one membership the selection is implied,
OrganizationPickerrenders nothing, and no header has to be sent for writes to work. - The remembered choice is a hint, never an authority. It selects a membership only when it matches one the server just returned, so a hand-edited value — or one left behind by a different user of the same browser — is discarded rather than honoured. The key is also cleared on sign-out.
- Switching re-resolves the session.
allowed_scopesis the effective set for the selected organization, sosetCurrentOrganizationperforms the freshgetCurrentUser()thatMeResponse.allowed_scopestells hosts they must, rather than leaving stale scopes in place. - A platform administrator is not a member of anything. They reach every organization and
belong to none, so their memberships are the wrong universe to derive a choice from: the server
insists such a caller names one, and a membership-driven picker offers them nothing to name.
Pass the organizations they may act in as
selectableOrganizationsand the picker offers those;organizationChoicesis the resulting union and is whatOrganizationPickerrenders and whatsetCurrentOrganizationvalidates against. Omit the prop and every existing behaviour is unchanged, because the union is then the memberships themselves. It is a display universe, not a grant — the server still refuses any organization the caller may not reach.
Send the selection on every request as ORGANIZATION_HEADER (X-Organization-Id). The same name
is defined server-side in terraform-suite-identity's identity/tenantscope. It is a claim:
the server verifies it against a scope it resolved itself and refuses anything the caller may not
reach, so this is not an authorization boundary.
Framework packages (React, MUI, Emotion, i18next, react-router) are peer dependencies — the consuming app provides a single copy at runtime.
Develop
Requires Node >=22.0.0 <25 (see engines in package.json).
npm install
npm run typecheck # tsc --noEmit
npm test # vitest
npm run build # tsup -> dist/ (ESM + .d.ts)Publishing (GitHub Packages)
Publishing is automated by .github/workflows/publish.yml:
create a GitHub Release tagged vX.Y.Z (matching package.json) and the workflow
builds, type-checks, tests, and runs npm publish to https://npm.pkg.github.com
using the repo's GITHUB_TOKEN.
Integrity guarantees for consumers:
The publish job refuses to publish unless the triggering ref is exactly the git tag matching
package.json's version — a manualworkflow_dispatchrun against an arbitrary branch is rejected, not just discouraged. This tag/version check is the guarantee enforced by code in this repository. The job also targets a GitHub Environment (release); any human-review gate on that environment must be configured as a required-reviewer rule in repo Settings (not tracked in git), so independent-review protection is not guaranteed by this repository alone.Before
npm publish, CI asserts the tarball (npm pack --dry-run) only containsdist/pluspackage.json/README.md/LICENSE/NOTICE— no source, tests, or config files ship.Every release generates GitHub Artifact Attestations — build provenance plus a CycloneDX SBOM — bound to the exact published npm tarball. Verify by fetching the tarball and checking it:
npm pack @4cloudguru/cloud-suite-ui gh attestation verify --repo 4cloudguru/cloud-suite-ui ./4cloudguru-cloud-suite-ui-*.tgzReleases also carry npm provenance, verifiable without the
ghCLI:npm audit signaturesCI runs
npm audit --audit-level=moderateon every push/PR, and CodeQL (javascript-typescript) runs on every push/PR plus a weekly schedule (see.github/workflows/codeql.yml).A
commitlintcheck on every PR enforces Conventional Commits, since release-please derives version bumps solely from commit messages.
See SECURITY.md for the vulnerability disclosure policy.
Consuming (in each frontend)
The package is public on the npm registry, so no .npmrc or auth token is needed:
npm install @4cloudguru/cloud-suite-uiimport { SuiteThemeProvider, PageHeader, useAuth } from '@4cloudguru/cloud-suite-ui'This package is a build-time dependency only; each app remains independently deployable. Wiring the two apps to consume it is intentionally a separate step.
This package is ESM-only (
"type": "module", a singleimportexport condition, norequire) — consume it from an ESM build/toolchain. It also declaresengines.node(>=22.0.0 <25); installing under an older/newer Node major is unsupported.
Internationalization (i18n)
Every component resolves user-facing copy through useTranslation()'s t(key, { defaultValue }),
so a host app's i18next configuration owns translation and an incomplete bundle still renders
readable English.
BrandingSettingsCard layers a host-supplied strings prop on top of that same contract, for
apps that already have translated copy for their own field labels/help text and would rather pass
it straight through than duplicate it into an i18next bundle. Precedence per field is
strings.fields[key]?.label ?? t('branding.fields.<key>.label', { defaultValue: '<English label>' })
(and the same for helperText) — an app that supplies no strings entry for a field still gets a
translatable label/helper text via t(); supplying an entry with e.g. errorText but no
helperText intentionally renders no helper at all (the host is presumed to own that field's copy
outright), rather than falling back to the t()-resolved English.
See CONTRIBUTING.md for the prop-contract stability convention that applies to
BrandingSettingsCard, NotificationChannelsSection, ApiKeyExpirySettingsCard, and
UIThemeConfig specifically — a separate concern from this section's translation contract, which
applies uniformly to every component.
Security model
Token custody is the host app's responsibility.
AuthProvideris parameterised by anAuthApiyour app implements (getCurrentUser/login/logout/refreshToken/etc.) — this library never reads or writes a token/cookie itself. Prefer an HttpOnly cookie over storing a bearer token inlocalStorage/sessionStorageif your backend supports it.onClearStorageis how you clear YOUR app's cached auth data when the session ends — on explicit logout AND when the session fails closed (a 401, a lapsed session, or a malformed/meresponse). Pass it whenever your app caches anything auth-related (a bearer token, query data keyed to the signed-in user) outside ofAuthProvider's own React state. One deliberate carve-out: a/methat returns 200 with asession_expires_atalready in the past is read as a disagreement between the client and server clocks, not as an expiry — no expiry is scheduled,onClearStoragedoes not fire, and aconsole.warnnames the skew. Otherwise a browser clock running ahead of the server would lock the user out on every/me(#178).hasScope/allowedScopesare UI-visibility gates only — NOT an authorization boundary. They hide/show nav items and affordances client-side; every backend endpoint must independently re-enforce authorization on every request regardless of what the client believes. The specialADMIN_SCOPE('admin') wildcard mirrors the backend's own admin-wildcard convention — do not rely on it as a security control in this library.refreshSession()logs out on failure (a failed token refresh clears the session rather than leaving a stale/ambiguous state);authErroron the auth context is a sanitized, display-safe string describing the most recent failed session-resolution call — never the raw error object, response body, headers, or URLs — if your app wants to distinguish a network blip from a real "not logged in" state.Pass an app-specific
storageKeytoConsentProvider/SuiteThemeProvider, and an app-specificgroupStateStorageKeytoSuiteLayout, if your app shares an origin with a sibling suite app — the default keys are generic and will collide otherwise (all three log a one-time console warning if you don't).SuiteLayoutclears its own persisted nav-group state on every transition to unauthenticated; consent preferences deliberately survive, since a consent decision is origin-scoped rather than session-scoped.isSafeUrlis the URL guard the shared components apply to host-supplied URLs before using them for navigation (SuiteSwitcher) or image sinks (SuiteLayout/SuiteThemeProviderbranding). It is exported so your app can apply the same allowlist (http/https/mailto/tel and relative paths only) to any backend- or user-influenced URL at its own boundary. Compose rather than re-derive it — an app that needs a narrower rule should callisSafeUrlfirst and layer its own check on top, so a future fix to the shared parsing logic reaches every consumer:export function isSafeExternalUrl(value: string | null | undefined): value is string { if (!isSafeUrl(value)) return false // shared base allowlist + normalisation if (/^[/#.]/.test(value.trim())) return true // relative — already screened above return new URL(value.trim()).protocol === 'https:' // app-specific narrowing }Route props (
SuiteLayout'sNavItem.pathandloginPath,DashboardCard'sto) must be in-app paths beginning with/. Anything absolute or protocol-relative is rejected with a console warning and falls back to/.
