@oxyhq/services 12.2.0 → 13.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/lib/commonjs/index.js +56 -6
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/components/AccountMenuButton.js +26 -9
  4. package/lib/commonjs/ui/components/AccountMenuButton.js.map +1 -1
  5. package/lib/commonjs/ui/components/AccountSwitcher.js +792 -0
  6. package/lib/commonjs/ui/components/AccountSwitcher.js.map +1 -0
  7. package/lib/commonjs/ui/components/ActiveAccountBanner.js +144 -0
  8. package/lib/commonjs/ui/components/ActiveAccountBanner.js.map +1 -0
  9. package/lib/commonjs/ui/context/OxyContext.js +64 -27
  10. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  11. package/lib/commonjs/ui/index.js +42 -0
  12. package/lib/commonjs/ui/index.js.map +1 -1
  13. package/lib/commonjs/ui/navigation/routes.js +4 -1
  14. package/lib/commonjs/ui/navigation/routes.js.map +1 -1
  15. package/lib/commonjs/ui/screens/AccountMembersScreen.js +469 -0
  16. package/lib/commonjs/ui/screens/AccountMembersScreen.js.map +1 -0
  17. package/lib/commonjs/ui/screens/AccountSettingsScreen.js +264 -0
  18. package/lib/commonjs/ui/screens/AccountSettingsScreen.js.map +1 -0
  19. package/lib/commonjs/ui/screens/AccountSwitcherScreen.js +51 -0
  20. package/lib/commonjs/ui/screens/AccountSwitcherScreen.js.map +1 -0
  21. package/lib/commonjs/ui/screens/{CreateManagedAccountScreen.js → CreateAccountScreen.js} +116 -25
  22. package/lib/commonjs/ui/screens/CreateAccountScreen.js.map +1 -0
  23. package/lib/commonjs/ui/screens/ManageAccountScreen.js +11 -11
  24. package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
  25. package/lib/module/index.js +16 -4
  26. package/lib/module/index.js.map +1 -1
  27. package/lib/module/ui/components/AccountMenuButton.js +26 -9
  28. package/lib/module/ui/components/AccountMenuButton.js.map +1 -1
  29. package/lib/module/ui/components/AccountSwitcher.js +786 -0
  30. package/lib/module/ui/components/AccountSwitcher.js.map +1 -0
  31. package/lib/module/ui/components/ActiveAccountBanner.js +141 -0
  32. package/lib/module/ui/components/ActiveAccountBanner.js.map +1 -0
  33. package/lib/module/ui/context/OxyContext.js +64 -27
  34. package/lib/module/ui/context/OxyContext.js.map +1 -1
  35. package/lib/module/ui/index.js +5 -0
  36. package/lib/module/ui/index.js.map +1 -1
  37. package/lib/module/ui/navigation/routes.js +4 -1
  38. package/lib/module/ui/navigation/routes.js.map +1 -1
  39. package/lib/module/ui/screens/AccountMembersScreen.js +464 -0
  40. package/lib/module/ui/screens/AccountMembersScreen.js.map +1 -0
  41. package/lib/module/ui/screens/AccountSettingsScreen.js +259 -0
  42. package/lib/module/ui/screens/AccountSettingsScreen.js.map +1 -0
  43. package/lib/module/ui/screens/AccountSwitcherScreen.js +48 -0
  44. package/lib/module/ui/screens/AccountSwitcherScreen.js.map +1 -0
  45. package/lib/module/ui/screens/{CreateManagedAccountScreen.js → CreateAccountScreen.js} +117 -26
  46. package/lib/module/ui/screens/CreateAccountScreen.js.map +1 -0
  47. package/lib/module/ui/screens/ManageAccountScreen.js +11 -11
  48. package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
  49. package/lib/typescript/commonjs/index.d.ts +7 -1
  50. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/ui/components/AccountMenuButton.d.ts +13 -4
  52. package/lib/typescript/commonjs/ui/components/AccountMenuButton.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/ui/components/AccountSwitcher.d.ts +47 -0
  54. package/lib/typescript/commonjs/ui/components/AccountSwitcher.d.ts.map +1 -0
  55. package/lib/typescript/commonjs/ui/components/{ActingAsBanner.d.ts → ActiveAccountBanner.d.ts} +1 -1
  56. package/lib/typescript/commonjs/ui/components/ActiveAccountBanner.d.ts.map +1 -0
  57. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +33 -5
  58. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/ui/index.d.ts +5 -0
  61. package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/ui/navigation/routes.d.ts +1 -1
  63. package/lib/typescript/commonjs/ui/navigation/routes.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts +10 -0
  65. package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts.map +1 -0
  66. package/lib/typescript/commonjs/ui/screens/AccountSettingsScreen.d.ts +12 -0
  67. package/lib/typescript/commonjs/ui/screens/AccountSettingsScreen.d.ts.map +1 -0
  68. package/lib/typescript/commonjs/ui/screens/AccountSwitcherScreen.d.ts +11 -0
  69. package/lib/typescript/commonjs/ui/screens/AccountSwitcherScreen.d.ts.map +1 -0
  70. package/lib/typescript/commonjs/ui/screens/CreateAccountScreen.d.ts +11 -0
  71. package/lib/typescript/commonjs/ui/screens/CreateAccountScreen.d.ts.map +1 -0
  72. package/lib/typescript/module/index.d.ts +7 -1
  73. package/lib/typescript/module/index.d.ts.map +1 -1
  74. package/lib/typescript/module/ui/components/AccountMenuButton.d.ts +13 -4
  75. package/lib/typescript/module/ui/components/AccountMenuButton.d.ts.map +1 -1
  76. package/lib/typescript/module/ui/components/AccountSwitcher.d.ts +47 -0
  77. package/lib/typescript/module/ui/components/AccountSwitcher.d.ts.map +1 -0
  78. package/lib/typescript/module/ui/components/{ActingAsBanner.d.ts → ActiveAccountBanner.d.ts} +1 -1
  79. package/lib/typescript/module/ui/components/ActiveAccountBanner.d.ts.map +1 -0
  80. package/lib/typescript/module/ui/context/OxyContext.d.ts +33 -5
  81. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  82. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  83. package/lib/typescript/module/ui/index.d.ts +5 -0
  84. package/lib/typescript/module/ui/index.d.ts.map +1 -1
  85. package/lib/typescript/module/ui/navigation/routes.d.ts +1 -1
  86. package/lib/typescript/module/ui/navigation/routes.d.ts.map +1 -1
  87. package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts +10 -0
  88. package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts.map +1 -0
  89. package/lib/typescript/module/ui/screens/AccountSettingsScreen.d.ts +12 -0
  90. package/lib/typescript/module/ui/screens/AccountSettingsScreen.d.ts.map +1 -0
  91. package/lib/typescript/module/ui/screens/AccountSwitcherScreen.d.ts +11 -0
  92. package/lib/typescript/module/ui/screens/AccountSwitcherScreen.d.ts.map +1 -0
  93. package/lib/typescript/module/ui/screens/CreateAccountScreen.d.ts +11 -0
  94. package/lib/typescript/module/ui/screens/CreateAccountScreen.d.ts.map +1 -0
  95. package/package.json +3 -3
  96. package/src/index.ts +18 -4
  97. package/src/ui/components/AccountMenuButton.tsx +25 -10
  98. package/src/ui/components/AccountSwitcher.tsx +763 -0
  99. package/src/ui/components/ActiveAccountBanner.tsx +130 -0
  100. package/src/ui/context/OxyContext.tsx +113 -35
  101. package/src/ui/index.ts +5 -0
  102. package/src/ui/navigation/routes.ts +8 -2
  103. package/src/ui/screens/AccountMembersScreen.tsx +481 -0
  104. package/src/ui/screens/AccountSettingsScreen.tsx +261 -0
  105. package/src/ui/screens/AccountSwitcherScreen.tsx +43 -0
  106. package/src/ui/screens/{CreateManagedAccountScreen.tsx → CreateAccountScreen.tsx} +140 -22
  107. package/src/ui/screens/ManageAccountScreen.tsx +19 -19
  108. package/lib/commonjs/ui/components/ActingAsBanner.js +0 -140
  109. package/lib/commonjs/ui/components/ActingAsBanner.js.map +0 -1
  110. package/lib/commonjs/ui/screens/CreateManagedAccountScreen.js.map +0 -1
  111. package/lib/module/ui/components/ActingAsBanner.js +0 -137
  112. package/lib/module/ui/components/ActingAsBanner.js.map +0 -1
  113. package/lib/module/ui/screens/CreateManagedAccountScreen.js.map +0 -1
  114. package/lib/typescript/commonjs/ui/components/ActingAsBanner.d.ts.map +0 -1
  115. package/lib/typescript/commonjs/ui/screens/CreateManagedAccountScreen.d.ts +0 -5
  116. package/lib/typescript/commonjs/ui/screens/CreateManagedAccountScreen.d.ts.map +0 -1
  117. package/lib/typescript/module/ui/components/ActingAsBanner.d.ts.map +0 -1
  118. package/lib/typescript/module/ui/screens/CreateManagedAccountScreen.d.ts +0 -5
  119. package/lib/typescript/module/ui/screens/CreateManagedAccountScreen.d.ts.map +0 -1
  120. package/src/ui/components/ActingAsBanner.tsx +0 -129
@@ -0,0 +1,481 @@
1
+ import type React from 'react';
2
+ import { useCallback, useMemo, useState } from 'react';
3
+ import {
4
+ View,
5
+ ScrollView,
6
+ TouchableOpacity,
7
+ ActivityIndicator,
8
+ Platform,
9
+ KeyboardAvoidingView,
10
+ } from 'react-native';
11
+ import { Ionicons } from '@expo/vector-icons';
12
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
13
+ import { toast, Dialog, useDialogControl } from '@oxyhq/bloom';
14
+ import { useTheme } from '@oxyhq/bloom/theme';
15
+ import { H1, Text } from '@oxyhq/bloom/typography';
16
+ import { Button } from '@oxyhq/bloom/button';
17
+ import { TextField, TextFieldInput } from '@oxyhq/bloom/text-field';
18
+ import { Divider } from '@oxyhq/bloom/divider';
19
+ import type { AccountMember, AccountRole } from '@oxyhq/core';
20
+ import { logger as loggerUtil } from '@oxyhq/core';
21
+ import type { BaseScreenProps } from '../types/navigation';
22
+ import Header from '../components/Header';
23
+ import { useOxy } from '../context/OxyContext';
24
+ import { useI18n } from '../hooks/useI18n';
25
+
26
+ /** Roles assignable via invite / role change — everything except `owner`. */
27
+ type AssignableRole = Exclude<AccountRole, 'owner'>;
28
+
29
+ const ASSIGNABLE_ROLES: AssignableRole[] = ['admin', 'editor', 'developer', 'billing', 'viewer'];
30
+
31
+ const errorMessage = (error: unknown, fallback: string): string =>
32
+ error instanceof Error && error.message ? error.message : fallback;
33
+
34
+ const isUserNotFound = (error: unknown): boolean => {
35
+ const message = error instanceof Error ? error.message.toLowerCase() : '';
36
+ return message.includes('user not found') || message.includes('not found');
37
+ };
38
+
39
+ /**
40
+ * Manage the members of an account (invite by username/email, change roles,
41
+ * remove, transfer ownership). Gated on the caller's `callerMembership`
42
+ * permissions. Receives the target `accountId` via navigation props.
43
+ */
44
+ const AccountMembersScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, accountId }) => {
45
+ const bloomTheme = useTheme();
46
+ const colors = bloomTheme.colors;
47
+ const { t } = useI18n();
48
+ const { oxyServices, canUsePrivateApi } = useOxy();
49
+ const queryClient = useQueryClient();
50
+
51
+ const id = typeof accountId === 'string' ? accountId : '';
52
+
53
+ const roleLabel = useCallback((role: AccountRole): string => {
54
+ return t(`accounts.roles.${role}.label`) || role.charAt(0).toUpperCase() + role.slice(1);
55
+ }, [t]);
56
+
57
+ const accountQuery = useQuery({
58
+ queryKey: ['accounts', 'detail', id],
59
+ queryFn: () => oxyServices.getAccount(id),
60
+ enabled: canUsePrivateApi && id.length > 0,
61
+ });
62
+
63
+ const callerMembership = accountQuery.data?.callerMembership ?? null;
64
+ const permissions = callerMembership?.permissions ?? [];
65
+ // The owner of an account (relationship 'self'/'owner') gets an implicit
66
+ // full-permission set even when no explicit membership row exists.
67
+ const isImplicitOwner = accountQuery.data
68
+ ? accountQuery.data.relationship !== 'member'
69
+ : false;
70
+ const can = useCallback(
71
+ (permission: string): boolean => isImplicitOwner || permissions.includes(permission),
72
+ [isImplicitOwner, permissions],
73
+ );
74
+
75
+ const canRead = can('members:read');
76
+ const canInvite = can('members:invite');
77
+ const canUpdate = can('members:update');
78
+ const canRemove = can('members:remove');
79
+ const canTransfer = can('ownership:transfer');
80
+
81
+ const membersQuery = useQuery({
82
+ queryKey: ['accounts', 'members', id],
83
+ queryFn: () => oxyServices.listAccountMembers(id),
84
+ enabled: canUsePrivateApi && id.length > 0 && canRead,
85
+ });
86
+ const members = useMemo<AccountMember[]>(() => membersQuery.data ?? [], [membersQuery.data]);
87
+ const ownerCount = useMemo(() => members.filter((m) => m.role === 'owner').length, [members]);
88
+
89
+ const invalidateMembers = useCallback(() => {
90
+ queryClient.invalidateQueries({ queryKey: ['accounts', 'members', id] });
91
+ queryClient.invalidateQueries({ queryKey: ['accounts', 'detail', id] });
92
+ }, [queryClient, id]);
93
+
94
+ // --- Invite form state ---
95
+ const [inviteIdentifier, setInviteIdentifier] = useState('');
96
+ const [inviteRole, setInviteRole] = useState<AssignableRole>('editor');
97
+ const [inviteError, setInviteError] = useState<string | null>(null);
98
+
99
+ const inviteMutation = useMutation({
100
+ mutationKey: ['accounts', 'members', 'invite', id],
101
+ mutationFn: (input: { usernameOrEmail: string; role: AssignableRole }) =>
102
+ oxyServices.inviteAccountMember(id, input),
103
+ onSuccess: () => {
104
+ setInviteIdentifier('');
105
+ setInviteRole('editor');
106
+ setInviteError(null);
107
+ invalidateMembers();
108
+ toast.success(t('accounts.members.toasts.added') || 'Member added');
109
+ },
110
+ onError: (error) => {
111
+ if (isUserNotFound(error)) {
112
+ const message = t('accounts.members.errors.userNotFound') || 'User not found';
113
+ setInviteError(message);
114
+ toast.error(message);
115
+ return;
116
+ }
117
+ toast.error(errorMessage(error, t('accounts.members.toasts.addFailed') || 'Failed to add member'));
118
+ },
119
+ });
120
+
121
+ const updateMutation = useMutation({
122
+ mutationKey: ['accounts', 'members', 'update', id],
123
+ mutationFn: (input: { memberId: string; role: AssignableRole }) =>
124
+ oxyServices.updateAccountMember(id, input.memberId, { role: input.role }),
125
+ onSuccess: () => {
126
+ invalidateMembers();
127
+ toast.success(t('accounts.members.toasts.roleUpdated') || 'Role updated');
128
+ },
129
+ onError: (error) => {
130
+ toast.error(errorMessage(error, t('accounts.members.toasts.roleFailed') || 'Failed to update role'));
131
+ },
132
+ });
133
+
134
+ const removeMutation = useMutation({
135
+ mutationKey: ['accounts', 'members', 'remove', id],
136
+ mutationFn: (memberId: string) => oxyServices.removeAccountMember(id, memberId),
137
+ onSuccess: () => {
138
+ invalidateMembers();
139
+ toast.success(t('accounts.members.toasts.removed') || 'Member removed');
140
+ },
141
+ onError: (error) => {
142
+ toast.error(errorMessage(error, t('accounts.members.toasts.removeFailed') || 'Failed to remove member'));
143
+ },
144
+ });
145
+
146
+ const transferMutation = useMutation({
147
+ mutationKey: ['accounts', 'members', 'transfer', id],
148
+ mutationFn: (userId: string) => oxyServices.transferAccountOwnership(id, { userId }),
149
+ onSuccess: () => {
150
+ invalidateMembers();
151
+ toast.success(t('accounts.members.toasts.transferred') || 'Ownership transferred');
152
+ },
153
+ onError: (error) => {
154
+ toast.error(errorMessage(error, t('accounts.members.toasts.transferFailed') || 'Failed to transfer ownership'));
155
+ },
156
+ });
157
+
158
+ const [memberToRemove, setMemberToRemove] = useState<AccountMember | null>(null);
159
+ const [memberToPromote, setMemberToPromote] = useState<AccountMember | null>(null);
160
+ const removeDialog = useDialogControl();
161
+ const transferDialog = useDialogControl();
162
+
163
+ const handleInvite = useCallback(() => {
164
+ const usernameOrEmail = inviteIdentifier.trim();
165
+ if (!usernameOrEmail) {
166
+ setInviteError(t('accounts.members.errors.identifierRequired') || 'Enter a username or email to invite');
167
+ return;
168
+ }
169
+ setInviteError(null);
170
+ inviteMutation.mutate({ usernameOrEmail, role: inviteRole });
171
+ }, [inviteIdentifier, inviteRole, inviteMutation, t]);
172
+
173
+ const handleChangeRole = useCallback((member: AccountMember, role: AccountRole) => {
174
+ if (role === member.role || role === 'owner') {
175
+ return;
176
+ }
177
+ updateMutation.mutate({ memberId: member._id, role });
178
+ }, [updateMutation]);
179
+
180
+ const confirmRemove = useCallback((member: AccountMember) => {
181
+ setMemberToRemove(member);
182
+ removeDialog.open();
183
+ }, [removeDialog]);
184
+
185
+ const confirmTransfer = useCallback((member: AccountMember) => {
186
+ setMemberToPromote(member);
187
+ transferDialog.open();
188
+ }, [transferDialog]);
189
+
190
+ const title = t('accounts.members.title') || 'Members';
191
+
192
+ if (!id) {
193
+ return (
194
+ <View className="flex-1 bg-bg">
195
+ <Header title={title} onBack={goBack} onClose={onClose} showBackButton showCloseButton elevation="subtle" />
196
+ <View className="flex-1 items-center justify-center px-screen-margin">
197
+ <Text className="text-body font-body text-text-secondary text-center">
198
+ {t('accounts.members.errors.missingAccount') || 'No account selected.'}
199
+ </Text>
200
+ </View>
201
+ </View>
202
+ );
203
+ }
204
+
205
+ return (
206
+ <View className="flex-1 bg-bg">
207
+ <Header title={title} onBack={goBack} onClose={onClose} showBackButton showCloseButton elevation="subtle" />
208
+
209
+ <KeyboardAvoidingView
210
+ className="flex-1"
211
+ behavior={Platform.OS === 'ios' ? 'padding' : undefined}
212
+ keyboardVerticalOffset={Platform.OS === 'ios' ? 88 : 0}
213
+ >
214
+ <ScrollView
215
+ className="flex-1"
216
+ contentContainerClassName="px-screen-margin pt-space-24 pb-space-32 gap-space-24"
217
+ showsVerticalScrollIndicator={false}
218
+ keyboardShouldPersistTaps="handled"
219
+ >
220
+ <View className="gap-space-8">
221
+ <H1 className="text-headerBold font-headerBold text-text">{title}</H1>
222
+ <Text className="text-body font-body text-text-secondary">
223
+ {t('accounts.members.subtitle') || 'People with access to this account.'}
224
+ </Text>
225
+ </View>
226
+
227
+ {!accountQuery.isLoading && !canRead ? (
228
+ <Text className="text-body font-body text-text-secondary text-center py-space-24">
229
+ {t('accounts.members.errors.noPermission') || 'You do not have permission to view members.'}
230
+ </Text>
231
+ ) : null}
232
+
233
+ {/* Invite form */}
234
+ {canInvite ? (
235
+ <View className="gap-space-12 p-space-16 rounded-radius-20 bg-fill">
236
+ <Text className="text-body font-bodyBold text-text">
237
+ {t('accounts.members.invite.title') || 'Add a member'}
238
+ </Text>
239
+ <TextField isInvalid={!!inviteError}>
240
+ <TextFieldInput
241
+ floatingLabel
242
+ label={t('accounts.members.invite.identifierLabel') || 'Username or email'}
243
+ value={inviteIdentifier}
244
+ onChangeText={(value) => {
245
+ setInviteIdentifier(value);
246
+ if (inviteError) setInviteError(null);
247
+ }}
248
+ isInvalid={!!inviteError}
249
+ autoCapitalize="none"
250
+ autoCorrect={false}
251
+ autoComplete="off"
252
+ />
253
+ </TextField>
254
+ {inviteError ? (
255
+ <Text className="text-caption font-caption px-space-4" style={{ color: colors.negative }}>
256
+ {inviteError}
257
+ </Text>
258
+ ) : null}
259
+
260
+ <Text className="text-caption font-caption text-text-secondary px-space-4">
261
+ {t('accounts.members.invite.roleLabel') || 'Role'}
262
+ </Text>
263
+ <View className="flex-row flex-wrap gap-space-8">
264
+ {ASSIGNABLE_ROLES.map((role) => {
265
+ const selected = role === inviteRole;
266
+ return (
267
+ <TouchableOpacity
268
+ key={role}
269
+ accessibilityRole="radio"
270
+ accessibilityState={{ selected }}
271
+ accessibilityLabel={roleLabel(role)}
272
+ onPress={() => setInviteRole(role)}
273
+ activeOpacity={0.7}
274
+ className="px-space-12 py-space-8 rounded-radius-full"
275
+ style={{
276
+ backgroundColor: selected ? colors.primary : colors.card,
277
+ borderWidth: 1,
278
+ borderColor: selected ? colors.primary : colors.border,
279
+ }}
280
+ >
281
+ <Text
282
+ className="text-caption font-bodyBold"
283
+ style={{ color: selected ? colors.background : colors.text }}
284
+ >
285
+ {roleLabel(role)}
286
+ </Text>
287
+ </TouchableOpacity>
288
+ );
289
+ })}
290
+ </View>
291
+
292
+ <Button
293
+ variant="primary"
294
+ onPress={handleInvite}
295
+ disabled={inviteMutation.isPending || !inviteIdentifier.trim()}
296
+ loading={inviteMutation.isPending}
297
+ accessibilityLabel={t('accounts.members.invite.submit') || 'Add member'}
298
+ className="w-full"
299
+ >
300
+ {t('accounts.members.invite.submit') || 'Add member'}
301
+ </Button>
302
+ </View>
303
+ ) : null}
304
+
305
+ {/* Member list */}
306
+ {canRead ? (
307
+ <View className="gap-space-8">
308
+ {membersQuery.isLoading ? (
309
+ <View className="items-center py-space-24">
310
+ <ActivityIndicator color={colors.primary} />
311
+ </View>
312
+ ) : members.length === 0 ? (
313
+ <Text className="text-body font-body text-text-secondary text-center py-space-24">
314
+ {t('accounts.members.empty') || 'No members yet.'}
315
+ </Text>
316
+ ) : (
317
+ <View className="rounded-radius-20 bg-fill overflow-hidden">
318
+ {members.map((member, index) => {
319
+ const isOwner = member.role === 'owner';
320
+ const isLastOwner = isOwner && ownerCount <= 1;
321
+ const canEditThisRole = canUpdate && !isOwner;
322
+ const canRemoveThisMember = canRemove && !isLastOwner && (!isOwner || canTransfer);
323
+ const canTransferToThis = canTransfer && !isOwner && member.status === 'active';
324
+ return (
325
+ <View key={member._id}>
326
+ {index > 0 ? <Divider color={colors.border} spacing={0} /> : null}
327
+ <View className="p-space-16 gap-space-8">
328
+ <View className="flex-row items-center gap-space-8">
329
+ <Text className="text-body font-bodyBold text-text flex-1" numberOfLines={1}>
330
+ {member.memberUserId}
331
+ </Text>
332
+ {isOwner ? (
333
+ <View className="px-space-8 py-space-4 rounded-radius-full" style={{ backgroundColor: colors.primarySubtle }}>
334
+ <Text className="text-caption font-bodyBold" style={{ color: colors.primary }}>
335
+ {roleLabel('owner')}
336
+ </Text>
337
+ </View>
338
+ ) : null}
339
+ {member.status !== 'active' ? (
340
+ <View className="px-space-8 py-space-4 rounded-radius-full" style={{ backgroundColor: colors.card }}>
341
+ <Text className="text-caption font-caption capitalize text-text-secondary">
342
+ {member.status}
343
+ </Text>
344
+ </View>
345
+ ) : null}
346
+ </View>
347
+
348
+ {/* Role chips (editable) or static role label */}
349
+ {canEditThisRole ? (
350
+ <View className="flex-row flex-wrap gap-space-8">
351
+ {ASSIGNABLE_ROLES.map((role) => {
352
+ const selected = role === member.role;
353
+ return (
354
+ <TouchableOpacity
355
+ key={role}
356
+ accessibilityRole="radio"
357
+ accessibilityState={{ selected }}
358
+ accessibilityLabel={roleLabel(role)}
359
+ onPress={() => handleChangeRole(member, role)}
360
+ disabled={updateMutation.isPending}
361
+ activeOpacity={0.7}
362
+ className="px-space-12 py-space-4 rounded-radius-full"
363
+ style={{
364
+ backgroundColor: selected ? colors.primary : colors.card,
365
+ borderWidth: 1,
366
+ borderColor: selected ? colors.primary : colors.border,
367
+ }}
368
+ >
369
+ <Text
370
+ className="text-caption font-bodyBold"
371
+ style={{ color: selected ? colors.background : colors.text }}
372
+ >
373
+ {roleLabel(role)}
374
+ </Text>
375
+ </TouchableOpacity>
376
+ );
377
+ })}
378
+ </View>
379
+ ) : !isOwner ? (
380
+ <Text className="text-caption font-caption text-text-secondary">
381
+ {roleLabel(member.role)}
382
+ </Text>
383
+ ) : null}
384
+
385
+ {/* Actions */}
386
+ {(canTransferToThis || canRemoveThisMember) ? (
387
+ <View className="flex-row gap-space-16 pt-space-4">
388
+ {canTransferToThis ? (
389
+ <TouchableOpacity
390
+ accessibilityRole="button"
391
+ accessibilityLabel={t('accounts.members.actions.transfer') || 'Transfer ownership'}
392
+ onPress={() => confirmTransfer(member)}
393
+ activeOpacity={0.7}
394
+ className="flex-row items-center gap-space-4"
395
+ >
396
+ <Ionicons name="swap-horizontal-outline" size={16} color={colors.icon} />
397
+ <Text className="text-caption font-bodyBold text-text-secondary">
398
+ {t('accounts.members.actions.transfer') || 'Transfer ownership'}
399
+ </Text>
400
+ </TouchableOpacity>
401
+ ) : null}
402
+ {canRemoveThisMember ? (
403
+ <TouchableOpacity
404
+ accessibilityRole="button"
405
+ accessibilityLabel={t('accounts.members.actions.remove') || 'Remove member'}
406
+ onPress={() => confirmRemove(member)}
407
+ activeOpacity={0.7}
408
+ className="flex-row items-center gap-space-4"
409
+ >
410
+ <Ionicons name="trash-outline" size={16} color={colors.error} />
411
+ <Text className="text-caption font-bodyBold" style={{ color: colors.error }}>
412
+ {t('accounts.members.actions.remove') || 'Remove'}
413
+ </Text>
414
+ </TouchableOpacity>
415
+ ) : null}
416
+ </View>
417
+ ) : null}
418
+ </View>
419
+ </View>
420
+ );
421
+ })}
422
+ </View>
423
+ )}
424
+ </View>
425
+ ) : null}
426
+ </ScrollView>
427
+ </KeyboardAvoidingView>
428
+
429
+ <Dialog
430
+ control={removeDialog}
431
+ title={t('accounts.members.removeConfirm.title') || 'Remove member'}
432
+ description={
433
+ t('accounts.members.removeConfirm.description')
434
+ || 'Remove this member from the account? They will lose all access.'
435
+ }
436
+ actions={[
437
+ {
438
+ label: t('accounts.members.actions.remove') || 'Remove',
439
+ color: 'destructive',
440
+ onPress: () => {
441
+ const target = memberToRemove;
442
+ setMemberToRemove(null);
443
+ if (target) {
444
+ removeMutation.mutate(target._id);
445
+ } else {
446
+ loggerUtil.debug('Remove confirmed with no pending member', { component: 'AccountMembersScreen' });
447
+ }
448
+ },
449
+ },
450
+ { label: t('common.cancel') || 'Cancel', color: 'cancel' },
451
+ ]}
452
+ />
453
+ <Dialog
454
+ control={transferDialog}
455
+ title={t('accounts.members.transferConfirm.title') || 'Transfer ownership'}
456
+ description={
457
+ t('accounts.members.transferConfirm.description')
458
+ || 'Transfer ownership of this account to this member? You will be demoted to admin. This cannot be undone.'
459
+ }
460
+ actions={[
461
+ {
462
+ label: t('accounts.members.actions.transfer') || 'Transfer ownership',
463
+ color: 'destructive',
464
+ onPress: () => {
465
+ const target = memberToPromote;
466
+ setMemberToPromote(null);
467
+ if (target) {
468
+ transferMutation.mutate(target.memberUserId);
469
+ } else {
470
+ loggerUtil.debug('Transfer confirmed with no pending member', { component: 'AccountMembersScreen' });
471
+ }
472
+ },
473
+ },
474
+ { label: t('common.cancel') || 'Cancel', color: 'cancel' },
475
+ ]}
476
+ />
477
+ </View>
478
+ );
479
+ };
480
+
481
+ export default AccountMembersScreen;