feat(mobile): organization management screens#4491
Conversation
Data layer for upcoming org-management screens (hub, members, credit activity, invoices): thin tRPC query hooks plus optimistic mutations for renaming an org, inviting/updating/removing members, deleting invites, and toggling the minimum balance alert.
Adds the org hub route and its screen: info card (name + rename, balance for money roles, seats), last-30-days usage stats, and nav rows to members/credit-activity/invoices/low-balance-alert. Consumes the Task 1 org data hooks; target nav routes are stubs for later tasks.
useOrgWithMembers/useOrgUsageStats/useOrgCreditTransactions/useOrgInvoices now accept organizationId: string | null and disable the query until it resolves, instead of firing with an empty-string id that the backend rejects. hub-screen.tsx passes organizationId through directly. Also default the org info-card skeleton to the 2-row shape since role (and therefore row count) isn't known while loading.
Extract ActionTile into its own file to keep profile-screen.tsx under the 300-line lint ceiling, then add a Manage/View org row that links to the organization hub screen added in Task 2.
Builds the members drill-in linked from the org hub: active-member rows with role/limit/remove management for owners, invited-member rows with share/revoke actions, sorted lists, and an invite-member entry point in the header for owner/billing roles.
Register invite-member, member-limit, and low-balance-alert as formSheet routes on the organization stack, and build their sheet content.
… sheets Member-limit sheet now shows a skeleton while loading and a "Member not found" message instead of a blank sheet. Low-balance-alert sheet defers mounting its form until settings load, so its enabled/threshold state isn't seeded from stale data. Shares one EMAIL_PATTERN in lib/utils instead of duplicating it, and blocks saving when any email in the list is malformed instead of silently dropping it.
…screen organizations.list returns balance in microdollars (see organizations.ts's total_microdollars_acquired - microdollars_used), but the hub screen rendered it directly, showing $50000000.00 for a $50 org.
Toasts are invisible under iOS formSheet screens since they're native view controllers layered above the root layout where sonner-native mounts. Render the mutation error inline near the submit button instead so invite/limit/alert failures are visible while the sheet is open. The centralized onError toast stays for non-sheet callers.
Split MemberLimitSheet's form into a child component that only mounts once member data exists, so the limit ref/state initialize from real data instead of the empty pre-load value. Also import ROLE_LABEL from member-row instead of re-declaring it in invited-member-row.
Code Review SummaryStatus: 7 Issues Found | Recommendation: Address before merge Executive SummaryThe mobile organization feature can sign ordinary members out on direct invoice-route access and bypasses required seat-capacity, trial read-only, and plan gates in member management. Overview
Issue Details (click to expand)WARNING
Files Reviewed (24 files)
Fix these issues in Kilo Cloud Previous Review Summary (commit dfd0be0)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit dfd0be0)Status: 1 Issue Found | Recommendation: Address before merge Executive SummaryThe organization rename control in the mobile hub screen is gated to Overview
Issue Details (click to expand)WARNING
Files Reviewed (24 files)
Reviewed by gpt-5.6-sol · Input: 200.4K · Output: 50.3K · Cached: 7.5M Review guidance: REVIEW.md from base branch |
|
|
||
| export function OrganizationInvoicesScreen() { | ||
| const { organizationId } = useOrgRole(); | ||
| const query = useOrgInvoices(organizationId); |
There was a problem hiding this comment.
WARNING: Direct route access signs ordinary members out
This query is enabled for every selected organization even though organizations.invoices requires owner or billing_manager. Expo routes remain directly addressable, and that role check throws tRPC UNAUTHORIZED (HTTP 401); the mobile query cache treats every 401 as an expired session and calls signOut. A member who opens this route directly is therefore logged out rather than denied access. Gate the query on isMoneyRole(role) before issuing it.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
|
|
||
| const isLoading = orgWithMembers.isLoading || !orgWithMembers.data; | ||
| const enableUsageLimits = orgWithMembers.data?.settings.enable_usage_limits !== false; | ||
| const canInvite = isMoneyRole(role); |
There was a problem hiding this comment.
WARNING: Full Teams organizations can still send invitations
canInvite only checks role, so a Teams org with requireSeats enabled still exposes the invite sheet when org.seatCount.used >= org.seatCount.total. The seat-billing contract requires the invitation UI to be disabled in that state, and the server deliberately does not enforce this UI-only limit, so mobile can create invitations beyond purchased capacity. Include the selected org's plan, requireSeats, and seat counts in this gate (while preserving billing-manager invitations, which do not consume seats).
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
|
|
||
| const isLoading = orgWithMembers.isLoading || !orgWithMembers.data; | ||
| const enableUsageLimits = orgWithMembers.data?.settings.enable_usage_limits !== false; | ||
| const canInvite = isMoneyRole(role); |
There was a problem hiding this comment.
WARNING: Child organizations expose an invitation flow that always fails
The role-only gate also shows the invite action for child organizations. inviteUserToOrganization rejects every direct child-org invitation with Child organizations cannot invite members, and the web member UI explicitly hides this action when parent_organization_id is set. Gate the action on the loaded organization not being a child.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| } | ||
|
|
||
| const isLoading = orgWithMembers.isLoading || !orgWithMembers.data; | ||
| const enableUsageLimits = orgWithMembers.data?.settings.enable_usage_limits !== false; |
There was a problem hiding this comment.
WARNING: Teams organizations can receive Enterprise-only usage-limit controls
This defaults the feature to enabled whenever enable_usage_limits is absent, without checking the organization plan. The existing organization UI only exposes daily limits when plan === 'enterprise', and the plan contract says Enterprise-only settings must not be exposed while the org is on Teams. Legacy Teams rows with a missing or retained true setting can therefore open the sheet and mutate limits because the server mutation has no plan check. Require the Enterprise plan as well as the setting.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| <MemberRow | ||
| key={member.id} | ||
| member={member} | ||
| canManage={isOwner && member.id !== currentUserId} |
There was a problem hiding this comment.
WARNING: Soft-expired organizations remain mutable from mobile
Member actions are gated only by role and identity, and the invite action above is similarly role-only. During the soft-expired trial stage the contract requires the UI to become read-only; server middleware intentionally blocks only hard-expired mutations. The web organization surface supplies this entitlement gate through OrganizationTrialWrapper/LockableContainer, but the mobile screens do not load or apply an equivalent status, so owners can keep changing roles, limits, invitations, and membership during the read-only period.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| const mutations = useOrganizationMutations(organizationId ?? ''); | ||
| const { email: myEmail } = useCurrentUserId(); | ||
|
|
||
| const [enabled, setEnabled] = useState(settings.minimum_balance !== undefined); |
There was a problem hiding this comment.
WARNING: A background refresh cannot update this form's snapshot
The hub normally leaves withMembers cached before this sheet opens, so the form mounts immediately from that cached object while React Query refetches it (default stale time is zero). enabled, both refs, and the uncontrolled defaultValues are initialized only once; if the refetch returns settings changed by another client, the form continues displaying and submitting the old values and can overwrite the newer configuration. Remount or explicitly reset the form when the refreshed settings change.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| const mutations = useOrganizationMutations(organizationId ?? ''); | ||
| const currentLimit = member.dailyUsageLimitUsd; | ||
|
|
||
| const limitRef = useRef(currentLimit != null ? String(currentLimit) : ''); |
There was a problem hiding this comment.
WARNING: A refreshed member limit is ignored by the form state
This sheet opens from the same withMembers cache populated by the members screen and then refetches it. If another owner changed the limit since that cached result, member.dailyUsageLimitUsd updates after the refetch but this ref and the uncontrolled input keep the value captured at first mount; pressing Save restores the stale limit. Key/remount the form from the refreshed member value or reset its ref and input when that value changes.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
Adds role-gated organization management to the mobile app, reached from the profile screen when an org is selected: Manage organization (owner / billing manager) or View organization (member).
What's included
Hub screen (
/organization)Members screen
enable_usage_limits === false), remove member (destructive confirm)Read-only money screens (owner/billing): credit activity list, invoices list — deliberately no tap actions and zero Stripe surface (App Store review)
Form sheets: invite member / daily usage limit / low balance alert — native formSheet presentation, inline mutation error display (toasts render behind native sheets — see notes)
All operations call existing tRPC procedures (
organizations.*); server enforces permissions, client gating is visibility only. Caller role derives from the already-cachedorganizations.list. No backend changes.Notable fixes found during e2e
organizations.listbalanceis microdollars — was rendered as dollarsVerification (local stack, iOS simulator, all three roles)
pnpm format && pnpm typecheck && pnpm lint && pnpm check:unusedall clean