Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 27 additions & 12 deletions apps/shell-ui/src/components/layout/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ import { CheckoutFlow } from './CheckoutFlow';
import { ApiKeyLogin } from './ApiKeyLogin';
import LoadingScreen from './LoadingScreen';
import { SS_PENDING_APP_ID } from '../../constants';
import { registerAndMapApps } from '../../lib/appLoader';
import type { ServerAppEntry } from '../../lib/appLoader';

// =============================================================================
// STYLES
Expand Down Expand Up @@ -182,23 +184,36 @@ const Shell: React.FC<ShellProps> = ({ config }) => {
// ── Connection state ──────────────────────────────────────────────────
const { client, isConnected, statusMessage } = useShellConnection();

// ── Apps — probe catalog + post-auth desktop metadata ─────────────────
// MF remotes are registered once at bootstrap from the probe — they
// never change after auth. Post-auth, ConnectResult.apps only adds
// desktop metadata (appStatus, onDesktop) onto existing probe entries.
// ── Apps — probe catalog + post-auth merge ────────────────────────────
// The pre-auth probe registers public MF remotes. Post-auth, the
// ConnectResult may include additional apps the user is entitled to
// (e.g. apps gated by requiredPermissions). Those need to be registered
// as MF remotes and merged into the app list so they can be launched.
const apps = useMemo(() => {
if (!identity?.apps?.length) return config.apps;

// Index desktop metadata by app id for fast lookup
const desktopById = new Map(
(identity.apps as Array<{ id: string; appStatus?: string; onDesktop?: boolean }>)
.map((a) => [a.id, a]),
);
// Index ConnectResult apps by id
const identityApps = identity.apps as Array<ServerAppEntry & { appStatus?: string; onDesktop?: boolean }>;
const identityById = new Map(identityApps.map((a) => [a.id, a]));

// Overlay desktop metadata onto probe entries
return config.apps.map((a) => {
const da = desktopById.get(a.id);
const probeIds = new Set(config.apps.map((a) => a.id));
const merged = config.apps.map((a) => {
const da = identityById.get(a.id);
return da ? { ...a, appStatus: da.appStatus, onDesktop: da.onDesktop } : a;
});

// Register and append apps that were NOT in the probe (e.g. permission-gated)
const newApps = identityApps.filter((a) => !probeIds.has(a.id) && a.entry && a.moduleId);
if (newApps.length > 0) {
const registered = registerAndMapApps(newApps);
for (const app of registered) {
const da = identityById.get(app.id);
merged.push(da ? { ...app, appStatus: da.appStatus, onDesktop: da.onDesktop } : app);
}
}

return merged;
}, [identity?.apps, config.apps]);
Comment thread
Rod-Christensen marked this conversation as resolved.

// =====================================================================
Expand Down Expand Up @@ -473,7 +488,7 @@ const Shell: React.FC<ShellProps> = ({ config }) => {
} : config;

const stripeKey = config.apiConfig.RR_STRIPE_PUBLISHABLE_KEY ?? '';
const orgId = identity?.organizations?.[0]?.id ?? '';
const orgId = identity?.organization?.id ?? '';

return (
<ShellIdentityContext.Provider value={identity}>
Expand Down
4 changes: 1 addition & 3 deletions apps/shell-ui/src/components/layout/ShellLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,9 +282,7 @@ export const ShellLayout: React.FC<ShellLayoutProps> = ({
{/* Client area */}
<div style={styles.overlayContainer}>
<div style={styles.clientArea}>
{subGateActive ? (
<div style={styles.appLoading}>Subscription required</div>
) : activeApp?.components?.App ? (
{activeApp?.components?.App ? (
<AppErrorBoundary key={activeAppId} appName={appName}>
<activeApp.components.App
isConnected={isConnected}
Expand Down
1 change: 0 additions & 1 deletion apps/shell-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,6 @@ export { useSubscriptions } from './hooks/useSubscriptions';
// =============================================================================

export { default as AccountPage } from './views/account/AccountPage';
export { default as BillingPage } from './views/billing/BillingPage';
export { default as SettingsPage } from './views/settings/SettingsPage';

// Hook for plugin views to subscribe to shell lifecycle events (iframe protocol)
Expand Down
144 changes: 114 additions & 30 deletions apps/shell-ui/src/views/account/AccountPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ import type {
ProfileUpdate,
BillingDetail,
CreditBalance,
CreditPack,
TransactionsResult,
UsageRollup,
} from 'rocketride';
import { useShellConnection } from '../../connection/ConnectionContext';
import { useAuthUser, useLogout } from '../../hooks/useAuthUser';
Expand Down Expand Up @@ -95,19 +96,18 @@ const AccountPage: React.FC = () => {
const [creditBalance, setCreditBalance] = useState<CreditBalance | null>(
(authUser as { credits?: CreditBalance })?.credits ?? null,
);
const [creditPacks, setCreditPacks] = useState<CreditPack[]>([]);
const [allPlans, setAllPlans] = useState<any[]>([]);
const [transactions, setTransactions] = useState<TransactionsResult | null>(null);
const [usageByUser, setUsageByUser] = useState<UsageRollup[]>([]);
const [usageByTeam, setUsageByTeam] = useState<UsageRollup[]>([]);
const [dashboardLoading, setDashboardLoading] = useState(false);

// ── Refresh signal (bumped by shell:accountUpdate) ─────────────────────
const [refreshSignal, setRefreshSignal] = useState(0);

// ── Section load error ──────────────────────────────────────────────────
const [sectionError, setSectionError] = useState<string | null>(null);

// ── Permission flags ────────────────────────────────────────────────────

/** Whether the current user is an org admin. */
const isOrgAdminFlag = authUser?.organizations?.[0]?.permissions?.includes('org.admin') ?? false;

// Keep profile in sync with server-pushed account updates, bump refresh
// signal for env, and bump reload counter to re-fetch the active section
useEffect(() => ConnectionManager.getInstance().on('shell:accountUpdate', (data) => {
Expand All @@ -122,8 +122,8 @@ const AccountPage: React.FC = () => {

// ── Data loaders ────────────────────────────────────────────────────────

/** Derives the orgId from the auth user's first organization. */
const orgId = authUser?.organizations?.[0]?.id ?? '';
/** Derives the orgId from the auth user's organization. */
const orgId = authUser?.organization?.id ?? '';

/** Extracts a human-readable message from a thrown value. */
const errMsg = (e: unknown): string => e instanceof Error ? e.message : String(e);
Expand Down Expand Up @@ -189,41 +189,102 @@ const AccountPage: React.FC = () => {
// ── Load billing data ───────────────────────────────────────────────────

/** Fetches subscriptions, credit balance, and credit packs in parallel. */
/** Fetches all billing data in one shot: subscriptions, balance, plans, transactions, usage. */
const loadBilling = useCallback(async () => {
if (!client || !isConnected || !orgId) {
setBillingLoading(false);
return;
}
setBillingLoading(true);
setBillingError(null);
try {
const [subs, balance, packs] = await Promise.all([
const [subs, balance, plans, tx, byUser, byTeam] = await Promise.all([
client.billing.getDetails(orgId).catch((err: any) => {
setBillingError(err.message ?? 'Failed to load subscriptions');
return [] as BillingDetail[];
}),
client.billing.getCreditBalance(orgId).catch(() => null),
client.billing.listCreditPacks().catch(() => [] as CreditPack[]),
client.billing.getProductPrices('rocketride.pipeBuilder').catch(() => []),
client.billing.getTransactions(orgId, { page: 1, pageSize: 20 }).catch(() => null),
client.billing.getUsageByUser(orgId).catch(() => [] as UsageRollup[]),
client.billing.getUsageByTeam(orgId).catch(() => [] as UsageRollup[]),
]);
setSubscriptions(subs);
setCreditBalance(balance);
setCreditPacks(packs);
setAllPlans(plans);
setTransactions(tx);
setUsageByUser(byUser);
setUsageByTeam(byTeam);
} finally {
setBillingLoading(false);
setDashboardLoading(false);
}
}, [client, isConnected, orgId]);

// ── Load non-env data on section change ─────────────────────────────────
/** Fetches just the transactions page (for pagination). */
const handleTransactionPage = useCallback(async (page: number) => {
if (!client || !orgId) return;
const tx = await client.billing.getTransactions(orgId, { page, pageSize: 20 }).catch(() => null);
if (tx) { setTransactions(tx); }
}, [client, orgId]);

/** Purchase a top-up pack by charging the card on file. */
const handlePurchaseTopup = useCallback(async (plan: any) => {
if (!client || !orgId) throw new Error('Not connected');
const result = await client.billing.purchaseTopup(orgId, plan.stripePriceId);
if (result.status === 'succeeded') {
// Re-fetch billing data to reflect the new balance
loadBilling();
}
return result;
}, [client, orgId, loadBilling]);

/** Upgrade or downgrade an existing subscription to a new plan. */
const handleUpgradeSubscription = useCallback(async (appId: string, newPriceId: string) => {
if (!client || !orgId) throw new Error('Not connected');
await client.billing.upgradeSubscription(orgId, appId, newPriceId);
// Re-fetch billing data to reflect the updated subscription
loadBilling();
}, [client, orgId, loadBilling]);

// ── Load ALL data upfront on connect (badges, counts, billing) ──────────
useEffect(() => {
setSectionError(null);
if (!isConnected || !client) return;
loadProfile();
loadKeys();
loadOrg();
loadMembers();
loadTeams();
loadBilling();
}, [isConnected, client]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// ── Subscribe to billing events when billing tab is active ──────────────
useEffect(() => {
if (!client || !isConnected || section !== 'billing') return;
// Subscribe to billing ledger events via the wildcard monitor
client.addMonitor({ token: '*' }, ['billing']).catch(() => {});
// Listen for billing update events via ConnectionManager and re-fetch
const unsub = ConnectionManager.getInstance().on('shell:event', ({ event }: any) => {
if (event?.event === 'apaext_billing_update') {
loadBilling();
}
});
return () => {
client.removeMonitor({ token: '*' }, ['billing']).catch(() => {});
unsub();
};
}, [section, client, isConnected]);

// ── Reload current section on refresh signal ─────────────────────────────
useEffect(() => {
if (!reloadCounter || !isConnected || !client) return;
setSectionError(null);
if (section === 'profile') loadProfile();
else if (section === 'billing') loadBilling();
else if (section === 'api-keys') { loadProfile(); loadKeys(); }
else if (section === 'organization') loadOrg();
else if (section === 'members') { loadOrg(); loadMembers(); }
else if (section === 'teams') { loadOrg(); loadTeams(); }
}, [section, isConnected, client, reloadCounter]);
}, [reloadCounter]);
Comment thread
Rod-Christensen marked this conversation as resolved.

// ── Load team detail when a team is selected or data changes ────────────
useEffect(() => {
Expand All @@ -247,6 +308,12 @@ const AccountPage: React.FC = () => {
await client.account.setDefaultTeam(teamId);
}, [client]);

/** Switches the user's active organization. */
const handleSetDefaultOrg = useCallback(async (orgId: string) => {
if (!client) return;
await client.account.setDefaultOrg(orgId);
}, [client]);

/** Deletes the user account. */
const handleDeleteAccount = useCallback(async () => {
if (!client) return;
Expand Down Expand Up @@ -277,7 +344,7 @@ const AccountPage: React.FC = () => {
}, [client, loadKeys]);

/** Sends an invitation to a new organization member. */
const handleInviteMember = useCallback(async (params: { email: string; givenName: string; familyName: string; role: string }) => {
const handleInviteMember = useCallback(async (params: { email: string; givenName: string; familyName: string; role: string; teamAssignments?: Array<{ teamId: string; permissions: string[] }> }) => {
if (!client || !orgId) return;
await client.account.inviteMember(orgId, params);
await loadMembers();
Expand All @@ -297,6 +364,12 @@ const AccountPage: React.FC = () => {
await loadMembers();
}, [client, orgId, loadMembers]);

/** Resends the initialization email for a pending member. */
const handleResendInvite = useCallback(async (userId: string) => {
if (!client || !orgId) return;
await client.account.resendInvite(orgId, userId);
}, [client, orgId]);

/** Creates a new team. */
const handleCreateTeam = useCallback(async (name: string) => {
if (!client || !orgId) return;
Expand Down Expand Up @@ -360,17 +433,6 @@ const AccountPage: React.FC = () => {
window.open(url, '_blank', 'noopener');
}, [client, orgId]);

/**
* Initiates a credit pack purchase via Stripe hosted checkout.
* @param pack - The credit pack to purchase.
*/
const handleBuyCredits = useCallback(async (pack: CreditPack) => {
if (!client || !orgId) return;
const returnUrl = `${window.location.origin}${window.location.pathname}`;
const { url } = await client.billing.createCreditCheckout(orgId, pack.packId, returnUrl);
window.location.href = url;
}, [client, orgId]);

// ── Environment callbacks ───────────────────────────────────────────────

/**
Expand Down Expand Up @@ -399,6 +461,16 @@ const AccountPage: React.FC = () => {
await client.account.setEnv(scope, env, scopeId);
}, [client]);

// ── Memoized lookups ────────────────────────────────────────────────────
const memberNames = useMemo(
() => Object.fromEntries(members.map((m: any) => [m.userId, m.displayName || m.email || m.userId])),
[members],
);
const teamNames = useMemo(
() => Object.fromEntries(teams.map((t: any) => [t.id, t.name || t.id])),
[teams],
);

// ── Render ──────────────────────────────────────────────────────────────
return (
<div style={accountStyles.root}>
Expand All @@ -416,17 +488,28 @@ const AccountPage: React.FC = () => {
billingLoading={billingLoading}
billingError={billingError}
creditBalance={creditBalance}
creditPacks={creditPacks}
apps={appManifest}
onCancelSubscription={handleCancelSubscription}
onOpenPortal={handleOpenPortal}
onBuyCredits={handleBuyCredits}
transactions={transactions}
usageByUser={usageByUser}
usageByTeam={usageByTeam}
activeTasks={[]}
topupPlans={allPlans.filter((p: any) => p.metadata?.kind === 'topup').map((p: any) => ({ id: p.id, stripePriceId: p.stripePriceId, nickname: p.nickname, amountCents: p.amountCents, metadata: p.metadata }))}
allPlans={allPlans}
onPurchaseTopup={handlePurchaseTopup}
onUpgradeSubscription={handleUpgradeSubscription}
dashboardLoading={dashboardLoading}
onTransactionPage={handleTransactionPage}
memberNames={memberNames}
teamNames={teamNames}
section={section}
onSectionChange={setSection}
activeTeamId={activeTeamId}
onActiveTeamIdChange={setActiveTeamId}
onSaveProfile={handleSaveProfile}
onSetDefaultTeam={handleSetDefaultTeam}
onSetDefaultOrg={handleSetDefaultOrg}
onLogout={() => logout?.()}
onDeleteAccount={handleDeleteAccount}
onSaveOrgName={handleSaveOrgName}
Expand All @@ -435,6 +518,7 @@ const AccountPage: React.FC = () => {
onInviteMember={handleInviteMember}
onUpdateMemberRole={handleUpdateMemberRole}
onRemoveMember={handleRemoveMember}
onResendInvite={handleResendInvite}
onCreateTeam={handleCreateTeam}
onDeleteTeam={handleDeleteTeam}
onAddTeamMember={handleAddTeamMember}
Expand Down
Loading
Loading