@oxy.so/services 11.6.0 → 11.8.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/android/src/main/java/so/oxy/security/OxyCallerPolicy.kt +18 -0
  2. package/lib/commonjs/assets/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
  3. package/lib/commonjs/assets/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
  4. package/lib/commonjs/assets/assets/fonts/icons/manifest.json +6 -4
  5. package/lib/commonjs/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
  6. package/lib/commonjs/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
  7. package/lib/commonjs/assets/fonts/icons/manifest.json +6 -4
  8. package/lib/commonjs/ui/components/supervision/PersonPicker.js +69 -0
  9. package/lib/commonjs/ui/components/supervision/PersonPicker.js.map +1 -0
  10. package/lib/commonjs/ui/components/supervision/presentation.js +23 -0
  11. package/lib/commonjs/ui/components/supervision/presentation.js.map +1 -0
  12. package/lib/commonjs/ui/hooks/queries/useSupervision.js +37 -0
  13. package/lib/commonjs/ui/hooks/queries/useSupervision.js.map +1 -0
  14. package/lib/commonjs/ui/icons/subsetGlyphMaps.js +2 -0
  15. package/lib/commonjs/ui/icons/subsetGlyphMaps.js.map +1 -1
  16. package/lib/commonjs/ui/index.js +13 -0
  17. package/lib/commonjs/ui/index.js.map +1 -1
  18. package/lib/commonjs/ui/navigation/routes.js +1 -0
  19. package/lib/commonjs/ui/navigation/routes.js.map +1 -1
  20. package/lib/commonjs/ui/navigation/surfaceRegistry.js.map +1 -1
  21. package/lib/commonjs/ui/screens/CreateAccountScreen.js +10 -2
  22. package/lib/commonjs/ui/screens/CreateAccountScreen.js.map +1 -1
  23. package/lib/commonjs/ui/screens/ManageAccountScreen.js +7 -0
  24. package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
  25. package/lib/commonjs/ui/screens/SupervisionScreen.js +529 -0
  26. package/lib/commonjs/ui/screens/SupervisionScreen.js.map +1 -0
  27. package/lib/commonjs/ui/screens/index.js +7 -0
  28. package/lib/commonjs/ui/screens/index.js.map +1 -1
  29. package/lib/module/assets/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
  30. package/lib/module/assets/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
  31. package/lib/module/assets/assets/fonts/icons/manifest.json +6 -4
  32. package/lib/module/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
  33. package/lib/module/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
  34. package/lib/module/assets/fonts/icons/manifest.json +6 -4
  35. package/lib/module/ui/components/supervision/PersonPicker.js +65 -0
  36. package/lib/module/ui/components/supervision/PersonPicker.js.map +1 -0
  37. package/lib/module/ui/components/supervision/presentation.js +19 -0
  38. package/lib/module/ui/components/supervision/presentation.js.map +1 -0
  39. package/lib/module/ui/hooks/queries/useSupervision.js +32 -0
  40. package/lib/module/ui/hooks/queries/useSupervision.js.map +1 -0
  41. package/lib/module/ui/icons/subsetGlyphMaps.js +2 -0
  42. package/lib/module/ui/icons/subsetGlyphMaps.js.map +1 -1
  43. package/lib/module/ui/index.js +1 -0
  44. package/lib/module/ui/index.js.map +1 -1
  45. package/lib/module/ui/navigation/routes.js +1 -0
  46. package/lib/module/ui/navigation/routes.js.map +1 -1
  47. package/lib/module/ui/navigation/surfaceRegistry.js.map +1 -1
  48. package/lib/module/ui/screens/CreateAccountScreen.js +10 -2
  49. package/lib/module/ui/screens/CreateAccountScreen.js.map +1 -1
  50. package/lib/module/ui/screens/ManageAccountScreen.js +7 -0
  51. package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
  52. package/lib/module/ui/screens/SupervisionScreen.js +525 -0
  53. package/lib/module/ui/screens/SupervisionScreen.js.map +1 -0
  54. package/lib/module/ui/screens/index.js +1 -0
  55. package/lib/module/ui/screens/index.js.map +1 -1
  56. package/lib/typescript/commonjs/ui/components/supervision/PersonPicker.d.ts +6 -0
  57. package/lib/typescript/commonjs/ui/components/supervision/PersonPicker.d.ts.map +1 -0
  58. package/lib/typescript/commonjs/ui/components/supervision/presentation.d.ts +3 -0
  59. package/lib/typescript/commonjs/ui/components/supervision/presentation.d.ts.map +1 -0
  60. package/lib/typescript/commonjs/ui/hooks/queries/useSupervision.d.ts +475 -0
  61. package/lib/typescript/commonjs/ui/hooks/queries/useSupervision.d.ts.map +1 -0
  62. package/lib/typescript/commonjs/ui/icons/Ionicons.d.ts +1 -1
  63. package/lib/typescript/commonjs/ui/icons/MaterialCommunityIcons.d.ts +1 -1
  64. package/lib/typescript/commonjs/ui/icons/MaterialCommunityIcons.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/ui/icons/subsetGlyphMaps.d.ts +2 -0
  66. package/lib/typescript/commonjs/ui/icons/subsetGlyphMaps.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/ui/index.d.ts +1 -0
  68. package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/ui/navigation/routes.d.ts +1 -1
  70. package/lib/typescript/commonjs/ui/navigation/routes.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/ui/navigation/surfaceRegistry.d.ts +4 -0
  72. package/lib/typescript/commonjs/ui/navigation/surfaceRegistry.d.ts.map +1 -1
  73. package/lib/typescript/commonjs/ui/screens/CreateAccountScreen.d.ts.map +1 -1
  74. package/lib/typescript/commonjs/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  75. package/lib/typescript/commonjs/ui/screens/SupervisionScreen.d.ts +3 -0
  76. package/lib/typescript/commonjs/ui/screens/SupervisionScreen.d.ts.map +1 -0
  77. package/lib/typescript/commonjs/ui/screens/index.d.ts +1 -0
  78. package/lib/typescript/commonjs/ui/screens/index.d.ts.map +1 -1
  79. package/lib/typescript/commonjs/ui/utils/identityCrypto.d.ts +1 -0
  80. package/lib/typescript/commonjs/ui/utils/identityCrypto.d.ts.map +1 -1
  81. package/lib/typescript/module/ui/components/supervision/PersonPicker.d.ts +6 -0
  82. package/lib/typescript/module/ui/components/supervision/PersonPicker.d.ts.map +1 -0
  83. package/lib/typescript/module/ui/components/supervision/presentation.d.ts +3 -0
  84. package/lib/typescript/module/ui/components/supervision/presentation.d.ts.map +1 -0
  85. package/lib/typescript/module/ui/hooks/queries/useSupervision.d.ts +475 -0
  86. package/lib/typescript/module/ui/hooks/queries/useSupervision.d.ts.map +1 -0
  87. package/lib/typescript/module/ui/icons/Ionicons.d.ts +1 -1
  88. package/lib/typescript/module/ui/icons/MaterialCommunityIcons.d.ts +1 -1
  89. package/lib/typescript/module/ui/icons/MaterialCommunityIcons.d.ts.map +1 -1
  90. package/lib/typescript/module/ui/icons/subsetGlyphMaps.d.ts +2 -0
  91. package/lib/typescript/module/ui/icons/subsetGlyphMaps.d.ts.map +1 -1
  92. package/lib/typescript/module/ui/index.d.ts +1 -0
  93. package/lib/typescript/module/ui/index.d.ts.map +1 -1
  94. package/lib/typescript/module/ui/navigation/routes.d.ts +1 -1
  95. package/lib/typescript/module/ui/navigation/routes.d.ts.map +1 -1
  96. package/lib/typescript/module/ui/navigation/surfaceRegistry.d.ts +4 -0
  97. package/lib/typescript/module/ui/navigation/surfaceRegistry.d.ts.map +1 -1
  98. package/lib/typescript/module/ui/screens/CreateAccountScreen.d.ts.map +1 -1
  99. package/lib/typescript/module/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  100. package/lib/typescript/module/ui/screens/SupervisionScreen.d.ts +3 -0
  101. package/lib/typescript/module/ui/screens/SupervisionScreen.d.ts.map +1 -0
  102. package/lib/typescript/module/ui/screens/index.d.ts +1 -0
  103. package/lib/typescript/module/ui/screens/index.d.ts.map +1 -1
  104. package/lib/typescript/module/ui/utils/identityCrypto.d.ts +1 -0
  105. package/lib/typescript/module/ui/utils/identityCrypto.d.ts.map +1 -1
  106. package/package.json +5 -21
  107. package/src/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
  108. package/src/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
  109. package/src/assets/fonts/icons/manifest.json +6 -4
  110. package/src/ui/components/supervision/PersonPicker.tsx +67 -0
  111. package/src/ui/components/supervision/presentation.ts +17 -0
  112. package/src/ui/hooks/queries/useSupervision.ts +25 -0
  113. package/src/ui/icons/subsetGlyphMaps.ts +2 -0
  114. package/src/ui/index.ts +2 -0
  115. package/src/ui/navigation/routes.ts +2 -0
  116. package/src/ui/navigation/surfaceRegistry.ts +1 -0
  117. package/src/ui/screens/CreateAccountScreen.tsx +15 -1
  118. package/src/ui/screens/ManageAccountScreen.tsx +10 -0
  119. package/src/ui/screens/SupervisionScreen.tsx +593 -0
  120. package/src/ui/screens/index.ts +2 -0
@@ -0,0 +1,593 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { ScrollView, View, StyleSheet } from 'react-native';
3
+ import { useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
4
+ import { Button } from '@oxy.so/bloom/button';
5
+ import { Text } from '@oxy.so/bloom/typography';
6
+ import type { PurchaseApproval, SafetyPerson, ReauthProof } from '@oxy.so/contracts';
7
+ import { useOxy } from '../context/OxyContext';
8
+ import { useI18n } from '../hooks/useI18n';
9
+ import { useSurfaceHeader } from '../hooks/useSurfaceHeader';
10
+ import { useSupervision, supervisionQueryKeys } from '../hooks/queries/useSupervision';
11
+ import { useSignInMethods } from '../hooks/queries/useAuthMethods';
12
+ import { useAuthStore } from '../stores/authStore';
13
+ import { ReauthStep } from '../components/signIn/ReauthStep';
14
+ import { AccountFlowNote, AccountFlowNotice } from '../components/signIn/accountFlowParts';
15
+ import { OxyAuthLoading } from '../components/signIn/OxyAuthScreen';
16
+ import { PersonPicker } from '../components/supervision/PersonPicker';
17
+ import { formatApprovalAmount } from '../components/supervision/presentation';
18
+ import type { BaseScreenProps } from '../types/navigation';
19
+
20
+ type Confirmation = {
21
+ title: string;
22
+ description: string;
23
+ purchase?: PurchaseApproval;
24
+ run: (reauth: ReauthProof) => Promise<unknown>;
25
+ };
26
+ function SupervisionBody({
27
+ onClose,
28
+ goBack,
29
+ navigate,
30
+ approvalId,
31
+ childAccountId,
32
+ }: BaseScreenProps) {
33
+ const { oxyServices, user, activeSessionId } = useOxy(),
34
+ { t, locale } = useI18n(),
35
+ client = useQueryClient();
36
+ const state = useSupervision(),
37
+ methods = useSignInMethods();
38
+ const mounted = useRef(true);
39
+ useEffect(() => {
40
+ mounted.current = true;
41
+ return () => {
42
+ mounted.current = false;
43
+ };
44
+ }, []);
45
+ const [selectedChild, setSelectedChild] = useState<string | null>(
46
+ typeof childAccountId === 'string' && childAccountId.length <= 128 ? childAccountId : null,
47
+ ),
48
+ [addingGuardian, setAddingGuardian] = useState(false),
49
+ [addingContact, setAddingContact] = useState(false),
50
+ [confirmation, setConfirmation] = useState<Confirmation | null>(null),
51
+ [notice, setNotice] = useState<string | null>(null),
52
+ [error, setError] = useState<string | null>(null),
53
+ [busy, setBusy] = useState(false),
54
+ [purchaseId, setPurchaseId] = useState<string | null>(
55
+ typeof approvalId === 'string' && approvalId.length <= 128 ? approvalId : null,
56
+ ),
57
+ [cursor, setCursor] = useState<string | undefined>();
58
+ const accountId = user?.id ?? '';
59
+ const own = state.data?.self;
60
+ const childId =
61
+ selectedChild ??
62
+ (own?.status !== 'ended' ? own?.childAccountId : null) ??
63
+ state.data?.children[0]?.childAccountId ??
64
+ null;
65
+ const guardianForChild =
66
+ state.data?.children.some((child) => child.childAccountId === childId) ?? false;
67
+ const contacts = useQuery({
68
+ queryKey: ['oxy', 'safety', 'contacts', accountId, activeSessionId, childId],
69
+ queryFn: () => {
70
+ if (!childId) throw new Error('Select an account');
71
+ return oxyServices.safety.contacts(childId);
72
+ },
73
+ enabled: !!childId && !state.isError,
74
+ staleTime: 0,
75
+ gcTime: 0,
76
+ retry: false,
77
+ });
78
+ const purchases = useQuery({
79
+ queryKey: ['oxy', 'safety', 'purchases', accountId, activeSessionId, cursor],
80
+ queryFn: () => oxyServices.safety.purchaseApprovals({ cursor }),
81
+ enabled: !!state.data && !state.isError,
82
+ staleTime: 0,
83
+ gcTime: 0,
84
+ retry: false,
85
+ });
86
+ const detail = useQuery({
87
+ queryKey: ['oxy', 'safety', 'purchase', accountId, activeSessionId, purchaseId],
88
+ queryFn: () => {
89
+ if (!purchaseId) throw new Error('Select a purchase');
90
+ return oxyServices.safety.purchaseApproval(purchaseId);
91
+ },
92
+ enabled: !!purchaseId && !state.isError,
93
+ staleTime: 0,
94
+ gcTime: 0,
95
+ retry: false,
96
+ });
97
+ const merchants = useQuery({
98
+ queryKey: [
99
+ 'oxy',
100
+ 'safety',
101
+ 'purchase-merchants',
102
+ accountId,
103
+ activeSessionId,
104
+ detail.data?.id,
105
+ detail.data?.merchantAccountIds,
106
+ ],
107
+ queryFn: () => {
108
+ if (!detail.data) throw new Error('Select a purchase');
109
+ return oxyServices.safety.people({ accountIds: detail.data.merchantAccountIds });
110
+ },
111
+ enabled: !!detail.data && !detail.isError && !state.isError,
112
+ staleTime: 0,
113
+ gcTime: 0,
114
+ retry: false,
115
+ });
116
+ const merchantsReady =
117
+ !merchants.isError &&
118
+ !!detail.data &&
119
+ detail.data.merchantAccountIds.every((id) =>
120
+ merchants.data?.items.some(
121
+ (person) => person.accountId === id && (person.displayName || person.username),
122
+ ),
123
+ );
124
+ const identityIds = useMemo(
125
+ () =>
126
+ [
127
+ ...new Set([
128
+ accountId,
129
+ ...(state.data?.children.map((child) => child.childAccountId) ?? []),
130
+ ...(own?.guardianAccountId ? [own.guardianAccountId] : []),
131
+ ...(state.data?.invitations.flatMap((invitation) => [
132
+ invitation.childAccountId,
133
+ invitation.guardianAccountId,
134
+ ]) ?? []),
135
+ ...(contacts.data?.items.map((contact) => contact.contactAccountId) ?? []),
136
+ ]),
137
+ ].filter(Boolean),
138
+ [accountId, state.data, own?.guardianAccountId, contacts.data],
139
+ );
140
+ const chunks = useMemo(
141
+ () =>
142
+ Array.from({ length: Math.ceil(identityIds.length / 100) }, (_, index) =>
143
+ identityIds.slice(index * 100, index * 100 + 100),
144
+ ),
145
+ [identityIds],
146
+ );
147
+ const identities = useQueries({
148
+ queries: chunks.map((ids) => ({
149
+ queryKey: ['oxy', 'safety', 'people', accountId, activeSessionId, ids],
150
+ queryFn: () => oxyServices.safety.people({ accountIds: ids }),
151
+ staleTime: 0,
152
+ gcTime: 0,
153
+ retry: false,
154
+ enabled: !state.isError,
155
+ })),
156
+ });
157
+ const people = new Map(
158
+ identities
159
+ .flatMap((query) => (query.isError ? [] : (query.data?.items ?? [])))
160
+ .map((person) => [person.accountId, person]),
161
+ );
162
+ const label = (id: string | null | undefined) => {
163
+ const person = id ? people.get(id) : undefined;
164
+ return (
165
+ person?.displayName ?? (person?.username ? `@${person.username}` : t('supervision.account'))
166
+ );
167
+ };
168
+ const active = () => mounted.current && useAuthStore.getState().user?.id === accountId;
169
+ const refreshed = async () => {
170
+ await Promise.all([
171
+ client.invalidateQueries({ queryKey: supervisionQueryKeys.all }),
172
+ client.invalidateQueries({ queryKey: ['oxy', 'safety', 'contacts'] }),
173
+ client.invalidateQueries({ queryKey: ['oxy', 'safety', 'purchases'] }),
174
+ client.invalidateQueries({ queryKey: ['oxy', 'safety', 'purchase'] }),
175
+ ]);
176
+ };
177
+ const run = async (work: () => Promise<unknown>) => {
178
+ if (busy) return;
179
+ setBusy(true);
180
+ setError(null);
181
+ try {
182
+ await work();
183
+ if (active()) {
184
+ await refreshed();
185
+ setNotice(t('supervision.saved'));
186
+ }
187
+ } catch {
188
+ if (active()) setError(t('supervision.changeError'));
189
+ } finally {
190
+ if (active()) setBusy(false);
191
+ }
192
+ };
193
+ if (state.isError)
194
+ return (
195
+ <View style={styles.section}>
196
+ <AccountFlowNotice type="error">{t('supervision.loadError')}</AccountFlowNotice>
197
+ <Button onPress={() => void state.refetch()}>{t('supervision.retry')}</Button>
198
+ </View>
199
+ );
200
+ if (!state.data) return <OxyAuthLoading />;
201
+ if (confirmation) {
202
+ if (
203
+ methods.isError ||
204
+ (!methods.isLoading && !methods.data?.hasEmail && !methods.data?.hasPassword)
205
+ )
206
+ return (
207
+ <View style={styles.section}>
208
+ <AccountFlowNote>{t('supervision.reauthSetup')}</AccountFlowNote>
209
+ <Button onPress={() => setConfirmation(null)}>{t('common.back')}</Button>
210
+ <Button onPress={() => navigate?.('ManageAccount')}>
211
+ {t('supervision.accountSettings')}
212
+ </Button>
213
+ </View>
214
+ );
215
+ if (!methods.data) return <OxyAuthLoading />;
216
+ return (
217
+ <View style={styles.screen}>
218
+ <ReauthStep
219
+ title={confirmation.title}
220
+ description={confirmation.description}
221
+ action={confirmation.purchase ? 'supervision_purchase' : 'supervision'}
222
+ allowPassword={methods.data.hasPassword}
223
+ totpEnabled={methods.data.totpEnabled}
224
+ submitLabel={t('supervision.confirm')}
225
+ secondary={{ label: t('common.cancel'), onPress: () => setConfirmation(null) }}
226
+ onSubmit={async (reauth) => {
227
+ if (!active()) throw new Error(t('supervision.changeError'));
228
+ await confirmation.run(reauth);
229
+ if (active()) {
230
+ setConfirmation(null);
231
+ setAddingGuardian(false);
232
+ setAddingContact(false);
233
+ await refreshed();
234
+ setNotice(t('supervision.saved'));
235
+ }
236
+ }}
237
+ >
238
+ {confirmation.purchase ? <PurchaseEvidence purchase={confirmation.purchase} /> : null}
239
+ </ReauthStep>
240
+ </View>
241
+ );
242
+ }
243
+ const selectGuardian = (person: SafetyPerson) =>
244
+ setConfirmation({
245
+ title: t('supervision.invite'),
246
+ description: t('supervision.inviteConsent', {
247
+ name: person.displayName ?? person.username ?? t('supervision.account'),
248
+ }),
249
+ run: (reauth) =>
250
+ oxyServices.safety.inviteGuardian({
251
+ guardianAccountId: person.accountId,
252
+ consentVersion: '1',
253
+ reauth,
254
+ }),
255
+ });
256
+ return (
257
+ <ScrollView contentContainerStyle={styles.screen}>
258
+ <AccountFlowNote>{t('supervision.description')}</AccountFlowNote>
259
+ {notice ? <AccountFlowNotice>{notice}</AccountFlowNotice> : null}
260
+ {error ? <AccountFlowNotice type="error">{error}</AccountFlowNotice> : null}
261
+ <View style={styles.section}>
262
+ <Text style={styles.heading}>{t('supervision.yourAccount')}</Text>
263
+ {own && own.status !== 'ended' ? (
264
+ <>
265
+ <Text>
266
+ {t(
267
+ own.status === 'orphaned' || !own.guardianAccountId
268
+ ? 'supervision.orphaned'
269
+ : 'supervision.active',
270
+ { name: label(own.guardianAccountId) },
271
+ )}
272
+ </Text>
273
+ <AccountFlowNote>{t('supervision.activeDescription')}</AccountFlowNote>
274
+ <Button appearance="plain" onPress={() => navigate?.('HelpSupport')}>
275
+ {t('supervision.transitionHelp')}
276
+ </Button>
277
+ </>
278
+ ) : (
279
+ <>
280
+ <Text>{t('supervision.notActive')}</Text>
281
+ {state.data.children.length === 0 ? (
282
+ <Button onPress={() => setAddingGuardian(!addingGuardian)}>
283
+ {t('supervision.invite')}
284
+ </Button>
285
+ ) : null}
286
+ </>
287
+ )}
288
+ {addingGuardian ? <PersonPicker exclude={[accountId]} onSelect={selectGuardian} /> : null}
289
+ </View>
290
+ {state.data.invitations.length ? (
291
+ <View style={styles.section}>
292
+ <Text style={styles.heading}>{t('supervision.pending')}</Text>
293
+ <AccountFlowNote>{t('supervision.pendingDescription')}</AccountFlowNote>
294
+ {state.data.invitations.map((invitation) => (
295
+ <View key={invitation.id} style={styles.row}>
296
+ <Text>
297
+ {label(invitation.childAccountId)} → {label(invitation.guardianAccountId)}
298
+ </Text>
299
+ <Text>
300
+ {t('supervision.expires', {
301
+ date: new Date(invitation.expiresAt).toLocaleString(locale),
302
+ })}
303
+ </Text>
304
+ {invitation.guardianAccountId === accountId ? (
305
+ <Button
306
+ onPress={() =>
307
+ setConfirmation({
308
+ title: t('supervision.accept'),
309
+ description: t('supervision.acceptConsent', {
310
+ name: label(invitation.childAccountId),
311
+ }),
312
+ run: (reauth) =>
313
+ oxyServices.safety.acceptGuardianInvitation(invitation.id, {
314
+ consentVersion: '1',
315
+ reauth,
316
+ }),
317
+ })
318
+ }
319
+ >
320
+ {t('supervision.accept')}
321
+ </Button>
322
+ ) : null}
323
+ <Button
324
+ appearance="plain"
325
+ disabled={busy}
326
+ onPress={() =>
327
+ void run(() => oxyServices.safety.cancelGuardianInvitation(invitation.id))
328
+ }
329
+ >
330
+ {t('supervision.cancelInvitation')}
331
+ </Button>
332
+ </View>
333
+ ))}
334
+ </View>
335
+ ) : null}
336
+ {state.data.children.length ? (
337
+ <View style={styles.section}>
338
+ <Text style={styles.heading}>{t('supervision.children')}</Text>
339
+ {state.data.children.map((child) => (
340
+ <Button
341
+ key={child.childAccountId}
342
+ appearance={child.childAccountId === childId ? 'solid' : 'plain'}
343
+ onPress={() => {
344
+ setSelectedChild(child.childAccountId);
345
+ setAddingContact(false);
346
+ }}
347
+ >
348
+ {label(child.childAccountId)}
349
+ </Button>
350
+ ))}
351
+ </View>
352
+ ) : null}
353
+ {childId ? (
354
+ <View style={styles.section}>
355
+ <Text style={styles.heading}>{t('supervision.contacts', { name: label(childId) })}</Text>
356
+ <AccountFlowNote>{t('supervision.contactsDescription')}</AccountFlowNote>
357
+ {contacts.isError ? (
358
+ <AccountFlowNotice type="error">{t('supervision.loadError')}</AccountFlowNotice>
359
+ ) : (
360
+ contacts.data?.items.map((contact) => (
361
+ <View key={contact.contactAccountId} style={styles.row}>
362
+ <Text>{label(contact.contactAccountId)}</Text>
363
+ {guardianForChild ? (
364
+ <Button
365
+ appearance="plain"
366
+ onPress={() =>
367
+ setConfirmation({
368
+ title: t('supervision.removeContact'),
369
+ description: t('supervision.removeConsent', {
370
+ name: label(contact.contactAccountId),
371
+ }),
372
+ run: (reauth) =>
373
+ oxyServices.safety.revokeContact({
374
+ childAccountId: childId,
375
+ contactAccountId: contact.contactAccountId,
376
+ reauth,
377
+ }),
378
+ })
379
+ }
380
+ >
381
+ {t('supervision.removeContact')}
382
+ </Button>
383
+ ) : null}
384
+ </View>
385
+ ))
386
+ )}
387
+ {contacts.data?.items.length === 0 ? <Text>{t('supervision.noContacts')}</Text> : null}
388
+ {guardianForChild ? (
389
+ <>
390
+ <Button onPress={() => setAddingContact(!addingContact)}>
391
+ {t('supervision.addContact')}
392
+ </Button>
393
+ {addingContact ? (
394
+ <PersonPicker
395
+ exclude={[childId]}
396
+ onSelect={(person) =>
397
+ setConfirmation({
398
+ title: t('supervision.addContact'),
399
+ description: t('supervision.addConsent', {
400
+ name: person.displayName ?? person.username ?? t('supervision.account'),
401
+ }),
402
+ run: (reauth) =>
403
+ oxyServices.safety.approveContact({
404
+ childAccountId: childId,
405
+ contactAccountId: person.accountId,
406
+ reauth,
407
+ }),
408
+ })
409
+ }
410
+ />
411
+ ) : null}
412
+ </>
413
+ ) : null}
414
+ </View>
415
+ ) : null}
416
+ <View style={styles.section}>
417
+ <Text style={styles.heading}>{t('supervision.purchases')}</Text>
418
+ {purchases.isError ? (
419
+ <AccountFlowNotice type="error">{t('supervision.loadError')}</AccountFlowNotice>
420
+ ) : (
421
+ purchases.data?.items.map((purchase) => (
422
+ <Button key={purchase.id} appearance="plain" onPress={() => setPurchaseId(purchase.id)}>
423
+ {purchase.summary} ·{' '}
424
+ {formatApprovalAmount(purchase.amountMinor, purchase.currency, locale)} ·{' '}
425
+ {t(`supervision.status.${purchase.status}`)}
426
+ </Button>
427
+ ))
428
+ )}
429
+ {purchases.data?.items.length === 0 ? <Text>{t('supervision.noPurchases')}</Text> : null}
430
+ {purchases.data?.nextCursor ? (
431
+ <Button onPress={() => setCursor(purchases.data?.nextCursor ?? undefined)}>
432
+ {t('supervision.more')}
433
+ </Button>
434
+ ) : null}
435
+ {cursor ? (
436
+ <Button appearance="plain" onPress={() => setCursor(undefined)}>
437
+ {t('supervision.firstPage')}
438
+ </Button>
439
+ ) : null}
440
+ {purchaseId ? (
441
+ detail.isError ? (
442
+ <AccountFlowNotice type="error">{t('supervision.loadError')}</AccountFlowNotice>
443
+ ) : detail.data ? (
444
+ <>
445
+ <PurchaseEvidence purchase={detail.data} />
446
+ {detail.data.status === 'pending' &&
447
+ state.data.children.some(
448
+ (child) => child.childAccountId === detail.data?.childAccountId,
449
+ ) ? (
450
+ <View style={styles.row}>
451
+ <Button
452
+ disabled={!merchantsReady}
453
+ onPress={() => {
454
+ const purchase = detail.data;
455
+ if (purchase)
456
+ setConfirmation({
457
+ title: t('supervision.approvePurchase'),
458
+ description: t('supervision.purchaseConsent'),
459
+ purchase,
460
+ run: async (reauth) => {
461
+ const identities = await oxyServices.safety.people({
462
+ accountIds: purchase.merchantAccountIds,
463
+ });
464
+ if (
465
+ purchase.merchantAccountIds.some(
466
+ (id) =>
467
+ !identities.items.some(
468
+ (person) =>
469
+ person.accountId === id &&
470
+ (person.displayName || person.username),
471
+ ),
472
+ )
473
+ )
474
+ throw new Error(t('supervision.merchantUnavailable'));
475
+ return oxyServices.safety.reviewPurchaseApproval(purchase.id, {
476
+ decision: 'approved',
477
+ reauth,
478
+ });
479
+ },
480
+ });
481
+ }}
482
+ >
483
+ {t('supervision.approvePurchase')}
484
+ </Button>
485
+ <Button
486
+ appearance="plain"
487
+ onPress={() => {
488
+ const purchase = detail.data;
489
+ if (purchase)
490
+ setConfirmation({
491
+ title: t('supervision.rejectPurchase'),
492
+ description: t('supervision.purchaseConsent'),
493
+ purchase,
494
+ run: (reauth) =>
495
+ oxyServices.safety.reviewPurchaseApproval(purchase.id, {
496
+ decision: 'rejected',
497
+ reauth,
498
+ }),
499
+ });
500
+ }}
501
+ >
502
+ {t('supervision.rejectPurchase')}
503
+ </Button>
504
+ </View>
505
+ ) : null}
506
+ </>
507
+ ) : (
508
+ <OxyAuthLoading />
509
+ )
510
+ ) : null}
511
+ </View>
512
+ <Button appearance="plain" onPress={goBack ?? onClose}>
513
+ {t('common.back')}
514
+ </Button>
515
+ </ScrollView>
516
+ );
517
+ }
518
+ function PurchaseEvidence({ purchase }: { purchase: PurchaseApproval }) {
519
+ const { oxyServices, user, activeSessionId } = useOxy(),
520
+ { t, locale } = useI18n();
521
+ const names = useQuery({
522
+ queryKey: [
523
+ 'oxy',
524
+ 'safety',
525
+ 'purchase-merchants',
526
+ user?.id,
527
+ activeSessionId,
528
+ purchase.id,
529
+ purchase.merchantAccountIds,
530
+ ],
531
+ queryFn: () => oxyServices.safety.people({ accountIds: purchase.merchantAccountIds }),
532
+ staleTime: 0,
533
+ gcTime: 0,
534
+ retry: false,
535
+ });
536
+ const money = (amount: number) => formatApprovalAmount(amount, purchase.currency, locale);
537
+ return (
538
+ <View style={styles.section} testID="supervision-purchase-evidence">
539
+ <Text style={styles.heading}>{purchase.summary}</Text>
540
+ <Text>{t(`supervision.status.${purchase.status}`)}</Text>
541
+ {purchase.lineItems.map((line) => (
542
+ <View key={`${line.merchantAccountId}:${line.itemReference}`} style={styles.row}>
543
+ <Text>{line.title}</Text>
544
+ {line.optionsText ? <Text>{line.optionsText}</Text> : null}
545
+ <Text>
546
+ {t('supervision.quantity', { quantity: line.quantity })} · {money(line.unitAmountMinor)}
547
+ </Text>
548
+ <Text>{money(line.lineAmountMinor)}</Text>
549
+ <Text>
550
+ {(!names.isError
551
+ ? (names.data?.items.find((item) => item.accountId === line.merchantAccountId)
552
+ ?.displayName ??
553
+ names.data?.items.find((item) => item.accountId === line.merchantAccountId)
554
+ ?.username)
555
+ : null) ?? `${t('supervision.merchantUnavailable')} (${line.merchantAccountId})`}
556
+ </Text>
557
+ <Text>{line.itemReference}</Text>
558
+ </View>
559
+ ))}
560
+ {(
561
+ [
562
+ ['shipping', purchase.shippingMinor],
563
+ ['tax', purchase.taxMinor],
564
+ ['discount', -purchase.discountMinor],
565
+ ['rounding', purchase.roundingAdjustmentMinor],
566
+ ['total', purchase.amountMinor],
567
+ ] as const
568
+ ).map(([key, amount]) => (
569
+ <Text key={key}>
570
+ {t(`supervision.${key}`)}: {money(amount)}
571
+ </Text>
572
+ ))}
573
+ <AccountFlowNote>{t('supervision.purchaseBound')}</AccountFlowNote>
574
+ <Text>
575
+ {t('supervision.expires', { date: new Date(purchase.expiresAt).toLocaleString(locale) })}
576
+ </Text>
577
+ </View>
578
+ );
579
+ }
580
+ export default function SupervisionScreen(props: BaseScreenProps) {
581
+ const { user, activeSessionId } = useOxy(),
582
+ { t } = useI18n();
583
+ useSurfaceHeader({ title: t('supervision.title') });
584
+ if (user?.kind !== 'personal')
585
+ return <AccountFlowNote>{t('supervision.personalOnly')}</AccountFlowNote>;
586
+ return <SupervisionBody key={`${user.id}:${activeSessionId}`} {...props} />;
587
+ }
588
+ const styles = StyleSheet.create({
589
+ screen: { padding: 20, gap: 20 },
590
+ section: { gap: 12, paddingVertical: 12 },
591
+ row: { gap: 6, paddingVertical: 8 },
592
+ heading: { fontSize: 18, fontWeight: '600' },
593
+ });
@@ -15,3 +15,5 @@ export { default as ConnectedAppsScreen } from './ConnectedAppsScreen';
15
15
  export { default as CreateAccountScreen } from './CreateAccountScreen';
16
16
  export { default as AccountMembersScreen } from './AccountMembersScreen';
17
17
  export { default as AccountSettingsScreen } from './AccountSettingsScreen';
18
+
19
+ export { default as SupervisionScreen } from './SupervisionScreen';