diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/_layout.tsx
new file mode 100644
index 0000000000..c06b11e953
--- /dev/null
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/_layout.tsx
@@ -0,0 +1,29 @@
+import { Stack } from 'expo-router';
+import { Platform, StatusBar, useWindowDimensions } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+// Mirrors apps/(app)/_layout.tsx's Android-safe full-sheet detent — Android
+// formSheets can't hit 1.0 without clipping under the status bar.
+export default function OrganizationLayout() {
+ const { height } = useWindowDimensions();
+ const { top } = useSafeAreaInsets();
+ const androidTopInset = top > 0 ? top : (StatusBar.currentHeight ?? 0);
+ const androidFullSheetDetent =
+ height > 0 ? Math.max(0.5, (height - androidTopInset) / height) : 1;
+ const fullSheetDetent = Platform.OS === 'android' ? androidFullSheetDetent : 1;
+
+ const sheetOptions = {
+ presentation: 'formSheet' as const,
+ sheetAllowedDetents: [0.5, fullSheetDetent] as [number, number],
+ sheetGrabberVisible: true,
+ headerShown: false,
+ };
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/credit-activity.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/credit-activity.tsx
new file mode 100644
index 0000000000..d16156956e
--- /dev/null
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/credit-activity.tsx
@@ -0,0 +1,5 @@
+import { OrganizationCreditActivityScreen } from '@/components/organization/credit-activity-screen';
+
+export default function OrganizationCreditActivityRoute() {
+ return ;
+}
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/index.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/index.tsx
new file mode 100644
index 0000000000..58cc2d6a91
--- /dev/null
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/index.tsx
@@ -0,0 +1,5 @@
+import { OrganizationHubScreen } from '@/components/organization/hub-screen';
+
+export default function OrganizationHubRoute() {
+ return ;
+}
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/invite-member.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/invite-member.tsx
new file mode 100644
index 0000000000..c0899b96e6
--- /dev/null
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/invite-member.tsx
@@ -0,0 +1,5 @@
+import { InviteMemberSheet } from '@/components/organization/invite-member-sheet';
+
+export default function InviteMemberRoute() {
+ return ;
+}
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/invoices.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/invoices.tsx
new file mode 100644
index 0000000000..495ded7437
--- /dev/null
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/invoices.tsx
@@ -0,0 +1,5 @@
+import { OrganizationInvoicesScreen } from '@/components/organization/invoices-screen';
+
+export default function OrganizationInvoicesRoute() {
+ return ;
+}
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/low-balance-alert.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/low-balance-alert.tsx
new file mode 100644
index 0000000000..7e314a39db
--- /dev/null
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/low-balance-alert.tsx
@@ -0,0 +1,5 @@
+import { LowBalanceAlertSheet } from '@/components/organization/low-balance-alert-sheet';
+
+export default function LowBalanceAlertRoute() {
+ return ;
+}
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/member-limit.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/member-limit.tsx
new file mode 100644
index 0000000000..c2f96f816a
--- /dev/null
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/member-limit.tsx
@@ -0,0 +1,8 @@
+import { useLocalSearchParams } from 'expo-router';
+
+import { MemberLimitSheet } from '@/components/organization/member-limit-sheet';
+
+export default function MemberLimitRoute() {
+ const { memberId } = useLocalSearchParams<{ memberId: string }>();
+ return ;
+}
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/members.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/members.tsx
new file mode 100644
index 0000000000..0f9af55ba4
--- /dev/null
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/members.tsx
@@ -0,0 +1,5 @@
+import { OrganizationMembersScreen } from '@/components/organization/members-screen';
+
+export default function OrganizationMembersRoute() {
+ return ;
+}
diff --git a/apps/mobile/src/components/organization/credit-activity-screen.tsx b/apps/mobile/src/components/organization/credit-activity-screen.tsx
new file mode 100644
index 0000000000..3445039529
--- /dev/null
+++ b/apps/mobile/src/components/organization/credit-activity-screen.tsx
@@ -0,0 +1,103 @@
+import { Receipt } from 'lucide-react-native';
+import { FlatList, View } from 'react-native';
+import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
+
+import { EmptyState } from '@/components/empty-state';
+import { ScreenHeader } from '@/components/screen-header';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Text } from '@/components/ui/text';
+import {
+ type CreditTransaction,
+ useOrgCreditTransactions,
+ useOrgRole,
+} from '@/lib/hooks/use-organization-queries';
+import { cn, firstNonEmpty, parseTimestamp } from '@/lib/utils';
+
+function humanizeCategory(category: string): string {
+ const spaced = category.replaceAll('_', ' ');
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
+}
+
+function CreditRowSkeleton() {
+ return (
+
+
+
+
+ );
+}
+
+function CreditRow({ transaction }: Readonly<{ transaction: CreditTransaction }>) {
+ const amount = transaction.amount_microdollars / 1_000_000;
+ const isPositive = amount >= 0;
+ const title = firstNonEmpty(
+ transaction.description,
+ transaction.credit_category ? humanizeCategory(transaction.credit_category) : undefined,
+ 'Credit transaction'
+ );
+
+ return (
+
+
+
+ {title}
+
+
+ {isPositive ? '+' : '-'}${Math.abs(amount).toFixed(2)}
+
+
+
+
+ {parseTimestamp(transaction.created_at).toLocaleDateString()}
+
+ {transaction.expiry_date != null && (
+
+ Expires {parseTimestamp(transaction.expiry_date).toLocaleDateString()}
+
+ )}
+
+
+ );
+}
+
+export function OrganizationCreditActivityScreen() {
+ const { organizationId } = useOrgRole();
+ const query = useOrgCreditTransactions(organizationId);
+
+ if (organizationId == null) {
+ return null;
+ }
+
+ const isLoading = query.isLoading || !query.data;
+ const transactions = query.data ?? [];
+
+ return (
+
+
+ {isLoading ? (
+
+
+
+
+
+ ) : (
+
+ item.id}
+ renderItem={({ item }) => }
+ contentContainerClassName="gap-3 px-6 pb-8 pt-4"
+ ListEmptyComponent={
+
+ }
+ />
+
+ )}
+
+ );
+}
diff --git a/apps/mobile/src/components/organization/hub-screen.tsx b/apps/mobile/src/components/organization/hub-screen.tsx
new file mode 100644
index 0000000000..d5c9b67581
--- /dev/null
+++ b/apps/mobile/src/components/organization/hub-screen.tsx
@@ -0,0 +1,146 @@
+import * as Haptics from 'expo-haptics';
+import { type Href, useRouter } from 'expo-router';
+import { Bell, FileText, Pencil, Receipt, Users } from 'lucide-react-native';
+import { useState } from 'react';
+import { Pressable, ScrollView, View } from 'react-native';
+import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';
+
+import { OrgUsageStats } from '@/components/organization/org-usage-stats';
+import { RenameOrgModal } from '@/components/organization/rename-org-modal';
+import { ScreenHeader } from '@/components/screen-header';
+import { ConfigureRow } from '@/components/ui/configure-row';
+import { KvRow } from '@/components/ui/kv-row';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Text } from '@/components/ui/text';
+import { useOrganizationMutations } from '@/lib/hooks/use-organization-mutations';
+import { isMoneyRole, useOrgRole, useOrgWithMembers } from '@/lib/hooks/use-organization-queries';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+
+function InfoCardSkeleton() {
+ return (
+
+
+
+
+ );
+}
+
+export function OrganizationHubScreen() {
+ const router = useRouter();
+ const colors = useThemeColors();
+ const { organizationId, role, org, isLoading } = useOrgRole();
+ const orgWithMembers = useOrgWithMembers(organizationId);
+ const mutations = useOrganizationMutations(organizationId ?? '');
+ const [renameVisible, setRenameVisible] = useState(false);
+
+ if (organizationId == null) {
+ return null;
+ }
+
+ const showMoney = isMoneyRole(role);
+ const minimumBalance = orgWithMembers.data?.settings.minimum_balance;
+ const lowBalanceSubtitle = minimumBalance != null ? `Below $${minimumBalance.toFixed(2)}` : 'Off';
+
+ return (
+
+
+
+
+ {isLoading || !org ? (
+
+
+
+ ) : (
+
+
+
+ {org.organizationName}
+
+ {showMoney && (
+ {
+ setRenameVisible(true);
+ }}
+ hitSlop={12}
+ accessibilityRole="button"
+ accessibilityLabel="Rename organization"
+ className="active:opacity-70"
+ >
+
+
+ )}
+
+ {showMoney && (
+
+ )}
+
+
+ )}
+
+
+
+
+
+ {
+ router.push('/(app)/(tabs)/(3_profile)/organization/members' as Href);
+ }}
+ />
+ {showMoney && (
+ <>
+ {
+ router.push('/(app)/(tabs)/(3_profile)/organization/credit-activity' as Href);
+ }}
+ />
+ {
+ router.push('/(app)/(tabs)/(3_profile)/organization/invoices' as Href);
+ }}
+ />
+ {
+ router.push('/(app)/(tabs)/(3_profile)/organization/low-balance-alert' as Href);
+ }}
+ />
+ >
+ )}
+
+
+
+ {renameVisible && org && (
+ {
+ mutations.rename.mutate(
+ { name },
+ {
+ onSuccess: () => {
+ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ },
+ }
+ );
+ }}
+ onClose={() => {
+ setRenameVisible(false);
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/apps/mobile/src/components/organization/invite-member-sheet.tsx b/apps/mobile/src/components/organization/invite-member-sheet.tsx
new file mode 100644
index 0000000000..bf93209677
--- /dev/null
+++ b/apps/mobile/src/components/organization/invite-member-sheet.tsx
@@ -0,0 +1,116 @@
+import * as Haptics from 'expo-haptics';
+import { useRouter } from 'expo-router';
+import { Check } from 'lucide-react-native';
+import { useRef, useState } from 'react';
+import { ActivityIndicator, Pressable, ScrollView, TextInput, View } from 'react-native';
+
+import { ROLE_LABEL } from '@/components/organization/member-row';
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { useOrganizationMutations } from '@/lib/hooks/use-organization-mutations';
+import { type OrgRole, useOrgRole } from '@/lib/hooks/use-organization-queries';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { cn, EMAIL_PATTERN } from '@/lib/utils';
+
+const INVITABLE_ROLES: OrgRole[] = ['member', 'billing_manager', 'owner'];
+
+export function InviteMemberSheet() {
+ const router = useRouter();
+ const colors = useThemeColors();
+ const { organizationId, role: myRole } = useOrgRole();
+ const mutations = useOrganizationMutations(organizationId ?? '');
+ const emailRef = useRef('');
+ const [canSubmit, setCanSubmit] = useState(false);
+ const isBillingManager = myRole === 'billing_manager';
+ const [role, setRole] = useState('member');
+
+ const onSubmit = () => {
+ const email = emailRef.current.trim().toLowerCase();
+ if (!EMAIL_PATTERN.test(email)) {
+ return;
+ }
+ mutations.invite.mutate(
+ { email, role: isBillingManager ? 'member' : role },
+ {
+ onSuccess: () => {
+ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ router.back();
+ },
+ }
+ );
+ };
+
+ return (
+
+ Invite member
+
+
+
+ Email
+
+ {
+ emailRef.current = value;
+ setCanSubmit(EMAIL_PATTERN.test(value.trim()));
+ }}
+ />
+
+
+ {isBillingManager ? (
+ Role: Member
+ ) : (
+
+
+ Role
+
+
+ {INVITABLE_ROLES.map((value, index) => {
+ const selected = role === value;
+ return (
+ {
+ setRole(value);
+ }}
+ accessibilityRole="radio"
+ accessibilityState={{ selected }}
+ >
+ {ROLE_LABEL[value]}
+ {selected && }
+
+ );
+ })}
+
+
+ )}
+
+ {mutations.invite.isError && (
+ {mutations.invite.error.message}
+ )}
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/organization/invited-member-row.tsx b/apps/mobile/src/components/organization/invited-member-row.tsx
new file mode 100644
index 0000000000..0247858f0a
--- /dev/null
+++ b/apps/mobile/src/components/organization/invited-member-row.tsx
@@ -0,0 +1,110 @@
+import { useActionSheet } from '@expo/react-native-action-sheet';
+import { Alert, Pressable, Share, View } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { Text } from '@/components/ui/text';
+import { useOrganizationMutations } from '@/lib/hooks/use-organization-mutations';
+import { type InvitedOrgMember } from '@/lib/hooks/use-organization-queries';
+import { cn, parseTimestamp } from '@/lib/utils';
+
+import { ROLE_LABEL } from './member-row';
+
+type InvitedMemberRowProps = {
+ invite: InvitedOrgMember;
+ /** Caller is owner. */
+ canManage: boolean;
+ organizationId: string;
+ /** Suppress bottom divider on the last row of a group. */
+ last?: boolean;
+};
+
+function inviteDateLabel(inviteDate: string | null): string | null {
+ if (inviteDate == null) {
+ return null;
+ }
+ return `Invited ${parseTimestamp(inviteDate).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`;
+}
+
+export function InvitedMemberRow({
+ invite,
+ canManage,
+ organizationId,
+ last,
+}: Readonly) {
+ const { bottom } = useSafeAreaInsets();
+ const { showActionSheetWithOptions } = useActionSheet();
+ const mutations = useOrganizationMutations(organizationId);
+ const dateLabel = inviteDateLabel(invite.inviteDate);
+
+ function confirmRevoke() {
+ Alert.alert('Revoke invitation', `Revoke the invitation sent to ${invite.email}?`, [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Revoke',
+ style: 'destructive',
+ onPress: () => {
+ mutations.deleteInvite.mutate({ inviteId: invite.inviteId });
+ },
+ },
+ ]);
+ }
+
+ function openActions() {
+ const options = ['Share invite link', 'Revoke invitation', 'Cancel'];
+ showActionSheetWithOptions(
+ {
+ options,
+ cancelButtonIndex: 2,
+ destructiveButtonIndex: 1,
+ containerStyle: { paddingBottom: bottom },
+ },
+ index => {
+ if (index === 0) {
+ void Share.share({ message: invite.inviteUrl });
+ } else if (index === 1) {
+ confirmRevoke();
+ }
+ }
+ );
+ }
+
+ const inner = (
+
+
+
+ {invite.email}
+
+ {dateLabel && (
+
+ {dateLabel}
+
+ )}
+
+
+
+ {ROLE_LABEL[invite.role]}
+
+
+
+ );
+
+ if (!canManage) {
+ return {inner};
+ }
+
+ return (
+
+ {inner}
+
+ );
+}
diff --git a/apps/mobile/src/components/organization/invoices-screen.tsx b/apps/mobile/src/components/organization/invoices-screen.tsx
new file mode 100644
index 0000000000..3c31a81d77
--- /dev/null
+++ b/apps/mobile/src/components/organization/invoices-screen.tsx
@@ -0,0 +1,103 @@
+import { FileText } from 'lucide-react-native';
+import { FlatList, View } from 'react-native';
+import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
+
+import { EmptyState } from '@/components/empty-state';
+import { ScreenHeader } from '@/components/screen-header';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Text } from '@/components/ui/text';
+import { type OrgInvoice, useOrgInvoices, useOrgRole } from '@/lib/hooks/use-organization-queries';
+import { cn, firstNonEmpty } from '@/lib/utils';
+
+const STATUS_META: Record = {
+ paid: { label: 'Paid', pillClass: 'bg-good', textClass: 'text-good-foreground' },
+ open: { label: 'Open', pillClass: 'bg-warn', textClass: 'text-warn-foreground' },
+ void: { label: 'Void', pillClass: 'bg-muted', textClass: 'text-muted-foreground' },
+};
+
+function statusMeta(status: string): { label: string; pillClass: string; textClass: string } {
+ return (
+ STATUS_META[status] ?? {
+ label: status.charAt(0).toUpperCase() + status.slice(1),
+ pillClass: 'bg-muted',
+ textClass: 'text-muted-foreground',
+ }
+ );
+}
+
+function InvoiceRowSkeleton() {
+ return (
+
+
+
+
+ );
+}
+
+function InvoiceRow({ invoice }: Readonly<{ invoice: OrgInvoice }>) {
+ const meta = statusMeta(invoice.status);
+ const title = firstNonEmpty(invoice.number, invoice.description, 'Invoice');
+
+ return (
+
+
+
+ {title}
+
+
+ ${(invoice.amount_due / 100).toFixed(2)}
+
+
+
+
+ {new Date(invoice.created * 1000).toLocaleDateString()}
+
+
+ {meta.label}
+
+
+
+ );
+}
+
+export function OrganizationInvoicesScreen() {
+ const { organizationId } = useOrgRole();
+ const query = useOrgInvoices(organizationId);
+
+ if (organizationId == null) {
+ return null;
+ }
+
+ const isLoading = query.isLoading || !query.data;
+ const invoices = query.data ?? [];
+
+ return (
+
+
+ {isLoading ? (
+
+
+
+
+
+ ) : (
+
+ item.id}
+ renderItem={({ item }) => }
+ contentContainerClassName="gap-3 px-6 pb-8 pt-4"
+ ListEmptyComponent={
+
+ }
+ />
+
+ )}
+
+ );
+}
diff --git a/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx b/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx
new file mode 100644
index 0000000000..ee39022f68
--- /dev/null
+++ b/apps/mobile/src/components/organization/low-balance-alert-sheet.tsx
@@ -0,0 +1,195 @@
+import * as Haptics from 'expo-haptics';
+import { useRouter } from 'expo-router';
+import { useRef, useState } from 'react';
+import { ActivityIndicator, ScrollView, Switch, TextInput, View } from 'react-native';
+
+import { Button } from '@/components/ui/button';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Text } from '@/components/ui/text';
+import { useCurrentUserId } from '@/lib/hooks/use-current-user-id';
+import { useOrganizationMutations } from '@/lib/hooks/use-organization-mutations';
+import {
+ type OrgWithMembers,
+ useOrgRole,
+ useOrgWithMembers,
+} from '@/lib/hooks/use-organization-queries';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { EMAIL_PATTERN } from '@/lib/utils';
+
+function parseThreshold(value: string): number | null {
+ const trimmed = value.trim();
+ const parsed = Number(trimmed);
+ if (trimmed === '' || !Number.isFinite(parsed) || parsed <= 0) {
+ return null;
+ }
+ return parsed;
+}
+
+function parseEmails(value: string): string[] {
+ return value
+ .split(',')
+ .map(email => email.trim())
+ .filter(email => email !== '');
+}
+
+type LowBalanceAlertFormProps = Readonly<{
+ organizationId: string | null;
+ settings: OrgWithMembers['settings'];
+}>;
+
+function LowBalanceAlertForm({ organizationId, settings }: LowBalanceAlertFormProps) {
+ const router = useRouter();
+ const colors = useThemeColors();
+ const mutations = useOrganizationMutations(organizationId ?? '');
+ const { email: myEmail } = useCurrentUserId();
+
+ const [enabled, setEnabled] = useState(settings.minimum_balance !== undefined);
+ const thresholdRef = useRef(
+ settings.minimum_balance != null ? String(settings.minimum_balance) : ''
+ );
+ const emailsRef = useRef((settings.minimum_balance_alert_email ?? []).join(', '));
+ const [canSave, setCanSave] = useState(() => {
+ const emails = parseEmails(emailsRef.current);
+ return (
+ !enabled ||
+ (parseThreshold(thresholdRef.current) != null &&
+ emails.length > 0 &&
+ emails.every(email => EMAIL_PATTERN.test(email)))
+ );
+ });
+
+ const revalidate = (nextEnabled: boolean, thresholdValue: string, emailsValue: string) => {
+ const emails = parseEmails(emailsValue);
+ setCanSave(
+ !nextEnabled ||
+ (parseThreshold(thresholdValue) != null &&
+ emails.length > 0 &&
+ emails.every(email => EMAIL_PATTERN.test(email)))
+ );
+ };
+
+ const onSave = () => {
+ const threshold = parseThreshold(thresholdRef.current);
+ const emails = parseEmails(emailsRef.current);
+ if (enabled && (threshold == null || emails.length === 0 || !canSave)) {
+ return;
+ }
+ mutations.updateMinimumBalanceAlert.mutate(
+ {
+ enabled,
+ ...(enabled && threshold != null
+ ? { minimum_balance: threshold, minimum_balance_alert_email: emails }
+ : {}),
+ },
+ {
+ onSuccess: () => {
+ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ router.back();
+ },
+ }
+ );
+ };
+
+ return (
+ <>
+
+ Enabled
+ {
+ void Haptics.selectionAsync();
+ setEnabled(value);
+ revalidate(value, thresholdRef.current, emailsRef.current);
+ }}
+ />
+
+
+ {enabled && (
+ <>
+
+
+ Alert below (USD)
+
+ {
+ thresholdRef.current = value;
+ revalidate(enabled, value, emailsRef.current);
+ }}
+ />
+
+
+
+
+ Notify emails
+
+ {
+ emailsRef.current = value;
+ revalidate(enabled, thresholdRef.current, value);
+ }}
+ />
+
+ Separate multiple emails with commas.
+
+
+ >
+ )}
+
+ {mutations.updateMinimumBalanceAlert.isError && (
+
+ {mutations.updateMinimumBalanceAlert.error.message}
+
+ )}
+
+
+ >
+ );
+}
+
+export function LowBalanceAlertSheet() {
+ const { organizationId } = useOrgRole();
+ const orgWithMembers = useOrgWithMembers(organizationId);
+
+ return (
+
+ Low balance alert
+
+ {orgWithMembers.data ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/mobile/src/components/organization/member-limit-sheet.tsx b/apps/mobile/src/components/organization/member-limit-sheet.tsx
new file mode 100644
index 0000000000..4276bf8853
--- /dev/null
+++ b/apps/mobile/src/components/organization/member-limit-sheet.tsx
@@ -0,0 +1,158 @@
+import * as Haptics from 'expo-haptics';
+import { useRouter } from 'expo-router';
+import { useRef, useState } from 'react';
+import { ActivityIndicator, ScrollView, TextInput, View } from 'react-native';
+
+import { Button } from '@/components/ui/button';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Text } from '@/components/ui/text';
+import { useOrganizationMutations } from '@/lib/hooks/use-organization-mutations';
+import {
+ type ActiveOrgMember,
+ useOrgRole,
+ useOrgWithMembers,
+} from '@/lib/hooks/use-organization-queries';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { firstNonEmpty } from '@/lib/utils';
+
+const MAX_DAILY_LIMIT_USD = 2000;
+
+function parseLimit(value: string): number | null {
+ const trimmed = value.trim();
+ if (trimmed === '') {
+ return null;
+ }
+ const parsed = Number(trimmed);
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > MAX_DAILY_LIMIT_USD) {
+ return null;
+ }
+ return parsed;
+}
+
+function isValidLimit(value: string): boolean {
+ return parseLimit(value) != null;
+}
+
+type MemberLimitFormProps = Readonly<{
+ memberId: string;
+ organizationId: string | null;
+ member: ActiveOrgMember;
+}>;
+
+function MemberLimitForm({ memberId, organizationId, member }: MemberLimitFormProps) {
+ const router = useRouter();
+ const colors = useThemeColors();
+ const mutations = useOrganizationMutations(organizationId ?? '');
+ const currentLimit = member.dailyUsageLimitUsd;
+
+ const limitRef = useRef(currentLimit != null ? String(currentLimit) : '');
+ const [canSave, setCanSave] = useState(isValidLimit(limitRef.current));
+
+ const onSaved = () => {
+ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ router.back();
+ };
+
+ const onSave = () => {
+ const parsed = parseLimit(limitRef.current);
+ if (parsed == null) {
+ return;
+ }
+ mutations.updateMember.mutate({ memberId, dailyUsageLimitUsd: parsed }, { onSuccess: onSaved });
+ };
+
+ const onRemove = () => {
+ mutations.updateMember.mutate({ memberId, dailyUsageLimitUsd: null }, { onSuccess: onSaved });
+ };
+
+ return (
+ <>
+
+
+ Limit (USD per day)
+
+ {
+ limitRef.current = value;
+ setCanSave(isValidLimit(value));
+ }}
+ />
+
+
+ {mutations.updateMember.isError && (
+ {mutations.updateMember.error.message}
+ )}
+
+
+
+ {currentLimit != null && (
+
+ )}
+ >
+ );
+}
+
+export function MemberLimitSheet({ memberId }: Readonly<{ memberId: string }>) {
+ const { organizationId } = useOrgRole();
+ const orgWithMembers = useOrgWithMembers(organizationId);
+ const member = orgWithMembers.data?.members.find(
+ (m): m is ActiveOrgMember => m.status === 'active' && m.id === memberId
+ );
+
+ if (orgWithMembers.isLoading) {
+ return (
+
+
+
+ Daily usage limit
+
+
+
+
+ );
+ }
+
+ if (!member) {
+ return (
+
+ Daily usage limit
+ Member not found
+
+ );
+ }
+
+ return (
+
+
+ Daily usage limit
+
+ {firstNonEmpty(member.name, member.email)}
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/organization/member-row.tsx b/apps/mobile/src/components/organization/member-row.tsx
new file mode 100644
index 0000000000..afe92935fd
--- /dev/null
+++ b/apps/mobile/src/components/organization/member-row.tsx
@@ -0,0 +1,161 @@
+import { useActionSheet } from '@expo/react-native-action-sheet';
+import * as Haptics from 'expo-haptics';
+import { type Href, useRouter } from 'expo-router';
+import { Alert, Pressable, View } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { Text } from '@/components/ui/text';
+import { useOrganizationMutations } from '@/lib/hooks/use-organization-mutations';
+import { type ActiveOrgMember, type OrgRole } from '@/lib/hooks/use-organization-queries';
+import { cn, firstNonEmpty } from '@/lib/utils';
+
+type MemberRowProps = {
+ member: ActiveOrgMember;
+ /** Caller is owner and this isn't their own row. */
+ canManage: boolean;
+ enableUsageLimits: boolean;
+ organizationId: string;
+ /** Suppress bottom divider on the last row of a group. */
+ last?: boolean;
+};
+
+export const ROLE_LABEL: Record = {
+ owner: 'Owner',
+ member: 'Member',
+ billing_manager: 'Billing manager',
+};
+
+const ROLE_OPTIONS: OrgRole[] = ['owner', 'member', 'billing_manager'];
+
+export function MemberRow({
+ member,
+ canManage,
+ enableUsageLimits,
+ organizationId,
+ last,
+}: Readonly) {
+ const router = useRouter();
+ const { bottom } = useSafeAreaInsets();
+ const { showActionSheetWithOptions } = useActionSheet();
+ const mutations = useOrganizationMutations(organizationId);
+ const displayName = firstNonEmpty(member.name, member.email);
+
+ function openRoleSheet() {
+ const options = [...ROLE_OPTIONS.map(role => ROLE_LABEL[role]), 'Cancel'];
+ showActionSheetWithOptions(
+ {
+ options,
+ cancelButtonIndex: options.length - 1,
+ containerStyle: { paddingBottom: bottom },
+ },
+ index => {
+ const role = index !== undefined ? ROLE_OPTIONS[index] : undefined;
+ if (!role) {
+ return;
+ }
+ mutations.updateMember.mutate(
+ { memberId: member.id, role },
+ {
+ onSuccess: () => {
+ void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ },
+ }
+ );
+ }
+ );
+ }
+
+ function confirmRemove() {
+ Alert.alert('Remove member', `Remove ${displayName} from this organization?`, [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Remove',
+ style: 'destructive',
+ onPress: () => {
+ mutations.removeMember.mutate(
+ { memberId: member.id },
+ {
+ onSuccess: () => {
+ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ },
+ }
+ );
+ },
+ },
+ ]);
+ }
+
+ function openActions() {
+ const options = [
+ 'Change role',
+ ...(enableUsageLimits ? ['Set daily usage limit'] : []),
+ 'Remove member',
+ 'Cancel',
+ ];
+ showActionSheetWithOptions(
+ {
+ options,
+ cancelButtonIndex: options.length - 1,
+ destructiveButtonIndex: options.length - 2,
+ containerStyle: { paddingBottom: bottom },
+ },
+ index => {
+ const label = index !== undefined ? options[index] : undefined;
+ if (label === 'Change role') {
+ openRoleSheet();
+ } else if (label === 'Set daily usage limit') {
+ router.push(
+ `/(app)/(tabs)/(3_profile)/organization/member-limit?memberId=${member.id}` as Href
+ );
+ } else if (label === 'Remove member') {
+ confirmRemove();
+ }
+ }
+ );
+ }
+
+ const inner = (
+
+
+
+ {displayName}
+
+
+ {member.email}
+
+
+
+
+
+ {ROLE_LABEL[member.role]}
+
+
+ {member.dailyUsageLimitUsd != null && (
+
+ ${member.dailyUsageLimitUsd.toFixed(2)}/day
+
+ )}
+
+
+ );
+
+ if (!canManage) {
+ return {inner};
+ }
+
+ return (
+
+ {inner}
+
+ );
+}
diff --git a/apps/mobile/src/components/organization/members-screen.tsx b/apps/mobile/src/components/organization/members-screen.tsx
new file mode 100644
index 0000000000..987cee4b17
--- /dev/null
+++ b/apps/mobile/src/components/organization/members-screen.tsx
@@ -0,0 +1,148 @@
+import { type Href, useRouter } from 'expo-router';
+import { UserPlus } from 'lucide-react-native';
+import { Pressable, ScrollView, View } from 'react-native';
+import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';
+
+import { InvitedMemberRow } from '@/components/organization/invited-member-row';
+import { MemberRow } from '@/components/organization/member-row';
+import { ScreenHeader } from '@/components/screen-header';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Text } from '@/components/ui/text';
+import { useCurrentUserId } from '@/lib/hooks/use-current-user-id';
+import {
+ type ActiveOrgMember,
+ type InvitedOrgMember,
+ isMoneyRole,
+ useOrgRole,
+ useOrgWithMembers,
+} from '@/lib/hooks/use-organization-queries';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { firstNonEmpty, parseTimestamp } from '@/lib/utils';
+
+function sortActiveMembers(members: ActiveOrgMember[]): ActiveOrgMember[] {
+ // eslint-disable-next-line unicorn/no-array-sort -- toSorted() is not available in Hermes
+ return [...members].sort((a, b) =>
+ firstNonEmpty(a.name, a.email).localeCompare(firstNonEmpty(b.name, b.email))
+ );
+}
+
+function sortInvitedMembers(invites: InvitedOrgMember[]): InvitedOrgMember[] {
+ // eslint-disable-next-line unicorn/no-array-sort -- toSorted() is not available in Hermes
+ return [...invites].sort((a, b) => {
+ if (a.inviteDate == null) {
+ return b.inviteDate == null ? 0 : 1;
+ }
+ if (b.inviteDate == null) {
+ return -1;
+ }
+ return parseTimestamp(b.inviteDate).getTime() - parseTimestamp(a.inviteDate).getTime();
+ });
+}
+
+function MemberRowSkeleton({ last }: Readonly<{ last?: boolean }>) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export function OrganizationMembersScreen() {
+ const router = useRouter();
+ const colors = useThemeColors();
+ const { organizationId, role } = useOrgRole();
+ const orgWithMembers = useOrgWithMembers(organizationId);
+ const { userId: currentUserId } = useCurrentUserId();
+
+ if (organizationId == null) {
+ return null;
+ }
+
+ const isLoading = orgWithMembers.isLoading || !orgWithMembers.data;
+ const enableUsageLimits = orgWithMembers.data?.settings.enable_usage_limits !== false;
+ const canInvite = isMoneyRole(role);
+ const isOwner = role === 'owner';
+
+ const activeMembers = sortActiveMembers(
+ orgWithMembers.data?.members.filter(m => m.status === 'active') ?? []
+ );
+ const invitedMembers = sortInvitedMembers(
+ orgWithMembers.data?.members.filter(m => m.status === 'invited') ?? []
+ );
+
+ return (
+
+ {
+ router.push('/(app)/(tabs)/(3_profile)/organization/invite-member' as Href);
+ }}
+ hitSlop={12}
+ accessibilityRole="button"
+ accessibilityLabel="Invite member"
+ className="active:opacity-70"
+ >
+
+
+ ) : undefined
+ }
+ />
+
+
+ Members
+ {isLoading ? (
+
+
+
+
+
+ ) : (
+
+ {activeMembers.map((member, index) => (
+
+ ))}
+
+ )}
+
+
+ {!isLoading && invitedMembers.length > 0 && (
+
+ Pending invitations
+
+ {invitedMembers.map((invite, index) => (
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/mobile/src/components/organization/org-usage-stats.tsx b/apps/mobile/src/components/organization/org-usage-stats.tsx
new file mode 100644
index 0000000000..206d924b77
--- /dev/null
+++ b/apps/mobile/src/components/organization/org-usage-stats.tsx
@@ -0,0 +1,64 @@
+import { View } from 'react-native';
+import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';
+
+import { Skeleton } from '@/components/ui/skeleton';
+import { Text } from '@/components/ui/text';
+import { useOrgUsageStats } from '@/lib/hooks/use-organization-queries';
+
+function StatTile({ label, value }: Readonly<{ label: string; value: string }>) {
+ return (
+
+ {value}
+ {label}
+
+ );
+}
+
+function StatTileSkeleton() {
+ return (
+
+
+
+
+ );
+}
+
+type OrgUsageStatsProps = {
+ organizationId: string;
+};
+
+/** "Last 30 days" eyebrow + 2x2 usage stat tile grid. Visible to all org roles. */
+export function OrgUsageStats({ organizationId }: Readonly) {
+ const { data, isLoading } = useOrgUsageStats(organizationId);
+
+ return (
+
+
+ Last 30 days
+
+ {isLoading || !data ? (
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/mobile/src/components/organization/rename-org-modal.tsx b/apps/mobile/src/components/organization/rename-org-modal.tsx
new file mode 100644
index 0000000000..51842a46a1
--- /dev/null
+++ b/apps/mobile/src/components/organization/rename-org-modal.tsx
@@ -0,0 +1,75 @@
+import { useEffect, useRef } from 'react';
+import { Modal, Platform, Pressable, TextInput, View } from 'react-native';
+
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+
+type RenameOrgModalProps = {
+ defaultName: string;
+ onSubmit: (name: string) => void;
+ onClose: () => void;
+};
+
+export function RenameOrgModal({ defaultName, onSubmit, onClose }: Readonly) {
+ const colors = useThemeColors();
+ const nameRef = useRef(defaultName);
+ const inputRef = useRef(null);
+
+ // autoFocus doesn't reliably raise the keyboard inside Modal on Android
+ useEffect(() => {
+ if (Platform.OS !== 'android') {
+ return undefined;
+ }
+ const timer = setTimeout(() => {
+ inputRef.current?.focus();
+ }, 100);
+ return () => {
+ clearTimeout(timer);
+ };
+ }, []);
+
+ return (
+
+
+
+ {
+ e.stopPropagation();
+ }}
+ >
+ Rename Organization
+ {
+ nameRef.current = val;
+ }}
+ autoFocus={Platform.OS !== 'android'}
+ maxLength={50}
+ />
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/profile-action-tile.tsx b/apps/mobile/src/components/profile-action-tile.tsx
new file mode 100644
index 0000000000..d874c0a1a5
--- /dev/null
+++ b/apps/mobile/src/components/profile-action-tile.tsx
@@ -0,0 +1,35 @@
+import { Pressable } from 'react-native';
+
+import { Text } from '@/components/ui/text';
+
+export function ActionTile({
+ icon: Icon,
+ label,
+ color,
+ onPress,
+ destructive,
+ disabled,
+}: {
+ icon: React.ComponentType<{ size?: number; color?: string }>;
+ label: string;
+ color: string;
+ onPress: () => void;
+ destructive?: boolean;
+ disabled?: boolean;
+}) {
+ return (
+
+
+
+ {label}
+
+
+ );
+}
diff --git a/apps/mobile/src/components/profile-screen.tsx b/apps/mobile/src/components/profile-screen.tsx
index 5cd6dfc67a..23e1ae465a 100644
--- a/apps/mobile/src/components/profile-screen.tsx
+++ b/apps/mobile/src/components/profile-screen.tsx
@@ -2,6 +2,7 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import * as Application from 'expo-application';
import { type Href, useRouter } from 'expo-router';
import {
+ Building2,
GitPullRequest,
KeyRound,
LifeBuoy,
@@ -17,6 +18,7 @@ import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanim
import { RestorePurchasesButton } from '@/components/kilo-pass/restore-purchases-button';
import { NotificationsCard } from '@/components/notifications-card';
+import { ActionTile } from '@/components/profile-action-tile';
import { CreditsCard } from '@/components/profile-credits-card';
import { ScreenHeader } from '@/components/screen-header';
import { ConfigureRow } from '@/components/ui/configure-row';
@@ -37,38 +39,6 @@ function providerIcon(_provider: string) {
return KeyRound;
}
-function ActionTile({
- icon: Icon,
- label,
- color,
- onPress,
- destructive,
- disabled,
-}: {
- icon: React.ComponentType<{ size?: number; color?: string }>;
- label: string;
- color: string;
- onPress: () => void;
- destructive?: boolean;
- disabled?: boolean;
-}) {
- return (
-
-
-
- {label}
-
-
- );
-}
-
export function ProfileScreen() {
const { signOut, token } = useAuth();
const router = useRouter();
@@ -92,6 +62,9 @@ export function ProfileScreen() {
const agentScope = organizationContextLoaded
? getProfileAgentScope(organizationId, orgs, organizationsFetching)
: undefined;
+ const selectedOrg = orgs?.find(org => org.organizationId === organizationId);
+ const orgRole = selectedOrg?.role;
+ const orgName = selectedOrg?.organizationName;
const { userId } = useCurrentUserId({ enabled: isAuthenticated });
@@ -201,6 +174,26 @@ export function ProfileScreen() {
/>
+ {/* Organization */}
+ {organizationId != null && (
+
+
+ Organization
+
+ {
+ router.push('/(app)/(tabs)/(3_profile)/organization' as Href);
+ }}
+ />
+
+ )}
+
{/* Linked accounts */}
diff --git a/apps/mobile/src/lib/hooks/use-organization-mutations.ts b/apps/mobile/src/lib/hooks/use-organization-mutations.ts
new file mode 100644
index 0000000000..3d3f14871f
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-organization-mutations.ts
@@ -0,0 +1,191 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { toast } from 'sonner-native';
+
+import {
+ type OrgListEntry,
+ type OrgRole,
+ type OrgWithMembers,
+} from '@/lib/hooks/use-organization-queries';
+import { trpcClient, useTRPC } from '@/lib/trpc';
+
+const onMutationError = (error: { message: string }) => {
+ toast.error(error.message || 'Something went wrong');
+};
+
+export function useOrganizationMutations(organizationId: string) {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+
+ const withMembersKey = trpc.organizations.withMembers.queryKey({ organizationId });
+ const listKey = trpc.organizations.list.queryKey();
+
+ const invalidateAll = async () => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: withMembersKey }),
+ queryClient.invalidateQueries({ queryKey: listKey }),
+ ]);
+ };
+
+ const invalidateWithMembers = async () => {
+ await queryClient.invalidateQueries({ queryKey: withMembersKey });
+ };
+
+ // Every optimistic mutation here only touches the withMembers cache, so the
+ // key is fixed rather than threaded through like use-kiloclaw-mutations.ts
+ // (which juggles many caches across a personal/org split).
+ function optimistic(updater: (old: OrgWithMembers, input: TInput) => OrgWithMembers) {
+ return {
+ onMutate: async (input: TInput) => {
+ await queryClient.cancelQueries({ queryKey: withMembersKey });
+ const previous = queryClient.getQueryData(withMembersKey);
+ queryClient.setQueryData(withMembersKey, old =>
+ old ? updater(old, input) : old
+ );
+ return { previous };
+ },
+ onError: (
+ error: { message: string },
+ _input: TInput,
+ context?: { previous?: OrgWithMembers }
+ ) => {
+ if (context?.previous) {
+ queryClient.setQueryData(withMembersKey, context.previous);
+ }
+ onMutationError(error);
+ },
+ onSettled: invalidateAll,
+ };
+ }
+
+ return {
+ rename: useMutation({
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ mutationFn: (input: { name: string }) =>
+ trpcClient.organizations.update.mutate({ organizationId, name: input.name }),
+ onMutate: async (input: { name: string }) => {
+ await Promise.all([
+ queryClient.cancelQueries({ queryKey: withMembersKey }),
+ queryClient.cancelQueries({ queryKey: listKey }),
+ ]);
+ const previousWithMembers = queryClient.getQueryData(withMembersKey);
+ const previousList = queryClient.getQueryData(listKey);
+ queryClient.setQueryData(withMembersKey, old =>
+ old ? { ...old, name: input.name } : old
+ );
+ queryClient.setQueryData(listKey, old =>
+ old
+ ? old.map(entry =>
+ entry.organizationId === organizationId
+ ? { ...entry, organizationName: input.name }
+ : entry
+ )
+ : old
+ );
+ return { previousWithMembers, previousList };
+ },
+ onError: (
+ error: { message: string },
+ _input,
+ context?: { previousWithMembers?: OrgWithMembers; previousList?: OrgListEntry[] }
+ ) => {
+ if (context?.previousWithMembers) {
+ queryClient.setQueryData(withMembersKey, context.previousWithMembers);
+ }
+ if (context?.previousList) {
+ queryClient.setQueryData(listKey, context.previousList);
+ }
+ onMutationError(error);
+ },
+ onSettled: invalidateAll,
+ }),
+
+ invite: useMutation({
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ mutationFn: (input: { email: string; role: OrgRole }) =>
+ trpcClient.organizations.members.invite.mutate({ organizationId, ...input }),
+ onSuccess: invalidateWithMembers,
+ onError: onMutationError,
+ onSettled: invalidateAll,
+ }),
+
+ updateMember: useMutation({
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ mutationFn: (input: {
+ memberId: string;
+ role?: OrgRole;
+ dailyUsageLimitUsd?: number | null;
+ }) => trpcClient.organizations.members.update.mutate({ organizationId, ...input }),
+ ...optimistic<{ memberId: string; role?: OrgRole; dailyUsageLimitUsd?: number | null }>(
+ (old, input) => ({
+ ...old,
+ members: old.members.map(member =>
+ member.status === 'active' && member.id === input.memberId
+ ? {
+ ...member,
+ ...(input.role !== undefined && { role: input.role }),
+ ...(input.dailyUsageLimitUsd !== undefined && {
+ dailyUsageLimitUsd: input.dailyUsageLimitUsd,
+ }),
+ }
+ : member
+ ),
+ })
+ ),
+ }),
+
+ removeMember: useMutation({
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ mutationFn: (input: { memberId: string }) =>
+ trpcClient.organizations.members.remove.mutate({ organizationId, ...input }),
+ ...optimistic<{ memberId: string }>((old, input) => ({
+ ...old,
+ members: old.members.filter(
+ member => !(member.status === 'active' && member.id === input.memberId)
+ ),
+ })),
+ }),
+
+ deleteInvite: useMutation({
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ mutationFn: (input: { inviteId: string }) =>
+ trpcClient.organizations.members.deleteInvite.mutate({ organizationId, ...input }),
+ ...optimistic<{ inviteId: string }>((old, input) => ({
+ ...old,
+ members: old.members.filter(
+ member => !(member.status === 'invited' && member.inviteId === input.inviteId)
+ ),
+ })),
+ }),
+
+ updateMinimumBalanceAlert: useMutation({
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ mutationFn: (input: {
+ enabled: boolean;
+ minimum_balance?: number;
+ minimum_balance_alert_email?: string[];
+ }) =>
+ trpcClient.organizations.settings.updateMinimumBalanceAlert.mutate({
+ organizationId,
+ ...input,
+ }),
+ ...optimistic<{
+ enabled: boolean;
+ minimum_balance?: number;
+ minimum_balance_alert_email?: string[];
+ }>((old, input) => {
+ if (input.enabled) {
+ return {
+ ...old,
+ settings: {
+ ...old.settings,
+ minimum_balance: input.minimum_balance,
+ minimum_balance_alert_email: input.minimum_balance_alert_email,
+ },
+ };
+ }
+ const { minimum_balance: _mb, minimum_balance_alert_email: _mbae, ...rest } = old.settings;
+ return { ...old, settings: rest };
+ }),
+ }),
+ };
+}
diff --git a/apps/mobile/src/lib/hooks/use-organization-queries.ts b/apps/mobile/src/lib/hooks/use-organization-queries.ts
new file mode 100644
index 0000000000..e637081866
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-organization-queries.ts
@@ -0,0 +1,80 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { useAuth } from '@/lib/auth/auth-context';
+import { useOrganization } from '@/lib/organization-context';
+import { useTRPC } from '@/lib/trpc';
+
+/**
+ * The current user's role in the active organization. `trpc.organizations.list`
+ * requires auth (not an active org selection), so it's gated on the token
+ * rather than on `organizationId` — mirrors profile-screen's `orgs` query.
+ */
+export function useOrgRole() {
+ const trpc = useTRPC();
+ const { token } = useAuth();
+ const { organizationId } = useOrganization();
+ const { data: orgs, isLoading } = useQuery({
+ ...trpc.organizations.list.queryOptions(),
+ enabled: token != null,
+ });
+ const org = orgs?.find(entry => entry.organizationId === organizationId);
+ return { organizationId, role: org?.role, org, isLoading };
+}
+
+export type OrgListEntry = NonNullable['org']>;
+export type OrgRole = OrgListEntry['role'];
+
+export function isMoneyRole(role: OrgRole | undefined): boolean {
+ return role === 'owner' || role === 'billing_manager';
+}
+
+export function useOrgWithMembers(organizationId: string | null) {
+ const trpc = useTRPC();
+ return useQuery(
+ trpc.organizations.withMembers.queryOptions(
+ { organizationId: organizationId ?? '' },
+ { enabled: organizationId != null }
+ )
+ );
+}
+
+export type OrgWithMembers = NonNullable['data']>;
+export type OrgMember = OrgWithMembers['members'][number];
+export type ActiveOrgMember = Extract;
+export type InvitedOrgMember = Extract;
+
+export function useOrgUsageStats(organizationId: string | null) {
+ const trpc = useTRPC();
+ return useQuery(
+ trpc.organizations.usageStats.queryOptions(
+ { organizationId: organizationId ?? '' },
+ { enabled: organizationId != null }
+ )
+ );
+}
+
+export function useOrgCreditTransactions(organizationId: string | null) {
+ const trpc = useTRPC();
+ return useQuery(
+ trpc.organizations.creditTransactions.queryOptions(
+ { organizationId: organizationId ?? '' },
+ { enabled: organizationId != null }
+ )
+ );
+}
+
+export type CreditTransaction = NonNullable<
+ ReturnType['data']
+>[number];
+
+export function useOrgInvoices(organizationId: string | null) {
+ const trpc = useTRPC();
+ return useQuery(
+ trpc.organizations.invoices.queryOptions(
+ { organizationId: organizationId ?? '', period: 'year' },
+ { enabled: organizationId != null }
+ )
+ );
+}
+
+export type OrgInvoice = NonNullable['data']>[number];
diff --git a/apps/mobile/src/lib/utils.ts b/apps/mobile/src/lib/utils.ts
index 86adc9e8a2..c503ec76af 100644
--- a/apps/mobile/src/lib/utils.ts
+++ b/apps/mobile/src/lib/utils.ts
@@ -5,6 +5,8 @@ function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+const EMAIL_PATTERN = /.+@.+\..+/;
+
/**
* Parse a Drizzle `mode: 'string'` timestamp into a Date.
* Hermes can't parse PostgreSQL's default format (`2026-03-13 14:30:00+00`),
@@ -64,4 +66,4 @@ function firstNonEmpty(...values: (string | null | undefined)[]): string {
return '';
}
-export { asyncNoop, cn, firstNonEmpty, parseTimestamp, timeAgo };
+export { asyncNoop, cn, EMAIL_PATTERN, firstNonEmpty, parseTimestamp, timeAgo };