@streamoid/settings
v0.6.6
Published
Shared Settings module (Account, Workspace, Organization, Billing, API Keys, Referral). The host application provides auth, workspace, API, navigation, toast, and image-upload implementations through a single **adapter** object — the package contains no h
Readme
@streamoid/settings
Shared Settings module (Account, Workspace, Organization, Billing, API Keys, Referral). The host application provides auth, workspace, API, navigation, toast, and image-upload implementations through a single adapter object — the package contains no host-specific code.
Install
npm install @streamoid/settings @streamoid/ui @streamoid/iconsreact, react-dom, @streamoid/ui, and @streamoid/icons are peer deps.
Usage
import { SettingsContent, type SettingsAdapters } from "@streamoid/settings";
function MySettingsPage() {
const adapters: SettingsAdapters = {
auth: {
user: currentUser,
refreshProfile: async () => { /* ... */ },
updateProfile: async (patch) => { /* PATCH /profile */ },
clearAuth: () => { /* clear local auth state */ },
logout: async () => { /* POST /logout */ },
},
workspace: {
current: { id: currentWorkspaceId, name, role },
list: async () => fetchWorkspaceList(),
create: async ({ name, imageUrl }) => { /* POST /workspace */ },
update: async (id, patch) => { /* PATCH /workspace/:id */ },
switch: async (id) => { /* set active workspace */ },
leave: async (id) => { /* DELETE /workspace/:id/leave */ },
delete: async (id) => { /* DELETE /workspace/:id */ },
},
api: {
fetchPlans: () => fetch("/billing/plans").then(r => r.json()),
fetchCurrentPlan: () => fetch("/billing/subscription").then(r => r.json()),
fetchCreditsUsage: async () => ({ used: 120, total: 500 }),
fetchInvoices: () => fetch("/billing/invoices").then(r => r.json()),
fetchUsageLogs: () => fetch("/billing/usage").then(r => r.json()),
downloadInvoice: (id) => fetch(`/billing/invoices/${id}`).then(r => r.json()),
exportAllInvoices: () => fetch(`/billing/invoices/export`).then(r => r.json()),
buyCredits: ({ quantity }) => fetch("/billing/credits", { method: "POST", body: JSON.stringify({ quantity }) }),
upgradePlan: (planId) => fetch(`/billing/upgrade/${planId}`, { method: "POST" }),
fetchMembers: () => fetch("/team/members").then(r => r.json()),
fetchPendingInvites: () => fetch("/team/invites").then(r => r.json()),
inviteMember: ({ email, role }) => fetch("/team/invites", { method: "POST", body: JSON.stringify({ email, role }) }),
updateMemberRole: (id, role) => fetch(`/team/members/${id}`, { method: "PATCH", body: JSON.stringify({ role }) }),
removeMember: (id) => fetch(`/team/members/${id}`, { method: "DELETE" }).then(() => undefined),
cancelInvite: (id) => fetch(`/team/invites/${id}`, { method: "DELETE" }).then(() => undefined),
resendInvite: (id) => fetch(`/team/invites/${id}/resend`, { method: "POST" }).then(() => undefined),
fetchTokens: () => fetch("/tokens").then(r => r.json()),
createToken: ({ name, scopes, expiresAt }) => fetch("/tokens", { method: "POST", body: JSON.stringify({ name, scopes, expiresAt }) }).then(r => r.json()),
revokeToken: (id) => fetch(`/tokens/${id}`, { method: "DELETE" }).then(() => undefined),
},
navigation: {
// `replace` matters once you wire up `onClose` below — otherwise a
// history-based close walks back through every tab the user visited.
navigateToTab: (tab) => router.replace(`/settings/${TAB_TO_URL[tab]}`),
openExternal: (url) => window.open(url, "_blank", "noopener"),
},
toast: {
success: (msg) => toast.success(msg),
error: (msg) => toast.error(msg),
info: (msg) => toast(msg),
},
imageUpload: {
upload: (file) => uploadToR2(file),
},
};
return (
<SettingsContent
activeTab={resolveSettingsTabFromPath(location.pathname)}
adapters={adapters}
onClose={() => router.back()}
/>
);
}Closing settings
Settings is a full page in every host, so the package cannot decide what
"closed" means — pass onClose (or adapters.navigation.close) and the shell
renders a × button in the header and dismisses on Escape. Pass neither and no
close affordance appears.
Escape defers to anything stacked on top of the panel. overlay-registry.ts
keeps an ordered stack of open modals — only the frontmost one acts on a press,
and the shell acts only once the stack is empty. The billing Plans sub-view
steps back to billing before the panel itself closes.
Modals added to this package should call useOverlayOpen(open, onClose) at the
top of the component. That is what registers them in the stack; skip it and
Escape will close the whole panel out from under the modal.
Navigation is scope-first (0.4.0)
Destinations are grouped by whose settings they are. workspace/* tabs act
on the workspace you are currently in; organization/* tabs act on the org
above it:
| Group | Tabs | URL |
|---|---|---|
| — | profile | /account |
| Workspace (named) | workspace, workspaceMembers, workspaceList | /workspace/general, /workspace/members, /workspace/all |
| Organization (named) | organization, organizationWorkspaces, organizationDomains | /organization/people, /organization/workspaces, /organization/domains |
| — | billing, apikey | /billing, /api-keys |
The group headings carry the entity's own name, so no leaf label repeats the word "Workspace" or "Organization" — and "Team", which named a workspace's member list without saying which workspace, is gone.
Host routing. Scoped slugs have two segments, so a host matching
/settings/:tab needs a second route for /settings/:tab/:sub.
resolveSettingsTabFromPath prefers the two-segment match and still resolves
the pre-0.4 slugs team, workspace and organization, so old links keep
working.
Organizations are optional. When organization.current is null the whole
group collapses to one Organization row leading to the create/join screen,
rather than a heading with no name and three empty pages under it.
Breaking: the "team" member of SettingsTab is now "workspaceMembers",
and WorkspaceTab is renamed WorkspaceListTab (the old name stays as a
deprecated alias).
Explaining the tabs
Every sub-nav destination carries a hover/focus tooltip sourced from the
exported TAB_DESCRIPTIONS map. Override the copy in your host by rendering
your own nav, or edit the map here so all hosts stay consistent — the latter is
usually what you want.
Adapter surface
| Adapter | Purpose |
|---|---|
| auth | Current user + profile mutations + logout |
| workspace | Current workspace + workspace CRUD + switch |
| api | Billing, team management, and API token endpoints |
| navigation | Imperative navigation between tabs and to external URLs |
| toast | User-facing success / error / info notifications |
| imageUpload | Single upload(file) for avatar / workspace image uploads |
See src/adapters.ts for full type definitions.
Status
- 0.1.0 — Adapter surface and package scaffold. Tab content lands in
subsequent releases as files migrate from
cxo-dashboardinto the package.
Workspace owner in the sidebar
useWorkspaceOrganization({ workspaceId, request, enabled, onOpenOrganization })
returns organization props for ScWorkspaceAccountMenu from @streamoid/ui.
The host supplies a stable authenticated GET transport returning the unified
API envelope. The adapter reads /workspace, finds the exact selected workspace,
and resolves only its organizationId through /organization/:id and /role.
It never chooses an organization from the user's membership list. An unowned
workspace, a loading request, and an unavailable owner remain distinct states.
Enable it while the menu is open. Requests are aborted on close/switch and stale responses cannot update the next workspace. Organization roles come from the organization API, never the workspace role. Guests may see an authorized owner name, but only members/admins get the optional navigation callback. The host owns routing; apps without organization settings omit the callback.
The adapter prefers organizationName from the authorized workspace response.
This lets invited workspace members see its owner without organization membership;
the separately checked organization role still controls settings navigation.
API Keys navigation and controls are visible only to the selected workspace's Admins. When the host supplies only a workspace id, its role is resolved from the authorized workspace list. Other roles opening the route see an Admin-only message. The page links to https://docs.streamoid.com.
Billing and API Keys are grouped under Workspace and restricted to workspace Admins in the menu and direct settings routes. Other members see a contact-admin explanation and a link to workspace members; billing content is not mounted. API documentation lives in an integration help card above the keys list.
Hosts should provide workspace.organizationRequest using the same authenticated
transport as their workspace popup. Workspace General and Members then display
the selected workspace owner independently of account organization membership.
Unknown or failed lookups are never labeled as an unowned workspace.
Hosts that delegate organization management to CXO can set
organization.managementUrl together with workspace.organizationRequest.
Settings then shows the selected workspace's owning organization and the
independently resolved organization role, rather than treating a null local
organization adapter as proof that the user has no membership. Management opens
in the central dashboard; an unavailable role never implies membership.
The settings navigation separates the Account heading from its Profile action.
Workspace identity remains in the main sidebar and the page breadcrumb.
My workspaces belongs to Account: it lists the signed-in user’s workspace memberships. Organization → Workspaces remains scoped to the organization. The existing workspace/all route is retained for bookmark compatibility.
Member lists use compact rows with a shared column grid, a short count summary, and embedded app favicon artwork in Access. App badges describe tool access; invite and credit permissions have separate Yes/No columns. Billing uses compact summary cards and usage-log rows while preserving all amounts and actions.
Shared settings appearance
SettingsContent mounts a route-scoped appearance layer. While settings is
visible, it normalizes the root rem scale to 16px, uses a 14px system-font body,
and shares light/dark canvas, card, text, divider and selection colors with the
host sidebar. Removing settings restores the host theme. Host wrappers must
remain transparent and use --alias-surface-canvas for the app floor; do not
paint --alias-surface-base behind the whole settings page.
