@oxyhq/services 12.2.0 → 13.0.1
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.
- package/lib/commonjs/index.js +56 -6
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/components/AccountMenuButton.js +26 -9
- package/lib/commonjs/ui/components/AccountMenuButton.js.map +1 -1
- package/lib/commonjs/ui/components/AccountSwitcher.js +792 -0
- package/lib/commonjs/ui/components/AccountSwitcher.js.map +1 -0
- package/lib/commonjs/ui/components/ActiveAccountBanner.js +144 -0
- package/lib/commonjs/ui/components/ActiveAccountBanner.js.map +1 -0
- package/lib/commonjs/ui/context/OxyContext.js +72 -28
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/index.js +42 -0
- package/lib/commonjs/ui/index.js.map +1 -1
- package/lib/commonjs/ui/navigation/routes.js +4 -1
- package/lib/commonjs/ui/navigation/routes.js.map +1 -1
- package/lib/commonjs/ui/screens/AccountMembersScreen.js +469 -0
- package/lib/commonjs/ui/screens/AccountMembersScreen.js.map +1 -0
- package/lib/commonjs/ui/screens/AccountSettingsScreen.js +264 -0
- package/lib/commonjs/ui/screens/AccountSettingsScreen.js.map +1 -0
- package/lib/commonjs/ui/screens/AccountSwitcherScreen.js +51 -0
- package/lib/commonjs/ui/screens/AccountSwitcherScreen.js.map +1 -0
- package/lib/commonjs/ui/screens/{CreateManagedAccountScreen.js → CreateAccountScreen.js} +116 -25
- package/lib/commonjs/ui/screens/CreateAccountScreen.js.map +1 -0
- package/lib/commonjs/ui/screens/ManageAccountScreen.js +11 -11
- package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
- package/lib/module/index.js +16 -4
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/components/AccountMenuButton.js +26 -9
- package/lib/module/ui/components/AccountMenuButton.js.map +1 -1
- package/lib/module/ui/components/AccountSwitcher.js +786 -0
- package/lib/module/ui/components/AccountSwitcher.js.map +1 -0
- package/lib/module/ui/components/ActiveAccountBanner.js +141 -0
- package/lib/module/ui/components/ActiveAccountBanner.js.map +1 -0
- package/lib/module/ui/context/OxyContext.js +72 -28
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/index.js +5 -0
- package/lib/module/ui/index.js.map +1 -1
- package/lib/module/ui/navigation/routes.js +4 -1
- package/lib/module/ui/navigation/routes.js.map +1 -1
- package/lib/module/ui/screens/AccountMembersScreen.js +464 -0
- package/lib/module/ui/screens/AccountMembersScreen.js.map +1 -0
- package/lib/module/ui/screens/AccountSettingsScreen.js +259 -0
- package/lib/module/ui/screens/AccountSettingsScreen.js.map +1 -0
- package/lib/module/ui/screens/AccountSwitcherScreen.js +48 -0
- package/lib/module/ui/screens/AccountSwitcherScreen.js.map +1 -0
- package/lib/module/ui/screens/{CreateManagedAccountScreen.js → CreateAccountScreen.js} +117 -26
- package/lib/module/ui/screens/CreateAccountScreen.js.map +1 -0
- package/lib/module/ui/screens/ManageAccountScreen.js +11 -11
- package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +7 -1
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/AccountMenuButton.d.ts +13 -4
- package/lib/typescript/commonjs/ui/components/AccountMenuButton.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/AccountSwitcher.d.ts +47 -0
- package/lib/typescript/commonjs/ui/components/AccountSwitcher.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/{ActingAsBanner.d.ts → ActiveAccountBanner.d.ts} +1 -1
- package/lib/typescript/commonjs/ui/components/ActiveAccountBanner.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +33 -5
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/index.d.ts +5 -0
- package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/navigation/routes.d.ts +1 -1
- package/lib/typescript/commonjs/ui/navigation/routes.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts +10 -0
- package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/screens/AccountSettingsScreen.d.ts +12 -0
- package/lib/typescript/commonjs/ui/screens/AccountSettingsScreen.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/screens/AccountSwitcherScreen.d.ts +11 -0
- package/lib/typescript/commonjs/ui/screens/AccountSwitcherScreen.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/screens/CreateAccountScreen.d.ts +11 -0
- package/lib/typescript/commonjs/ui/screens/CreateAccountScreen.d.ts.map +1 -0
- package/lib/typescript/module/index.d.ts +7 -1
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/AccountMenuButton.d.ts +13 -4
- package/lib/typescript/module/ui/components/AccountMenuButton.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/AccountSwitcher.d.ts +47 -0
- package/lib/typescript/module/ui/components/AccountSwitcher.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/{ActingAsBanner.d.ts → ActiveAccountBanner.d.ts} +1 -1
- package/lib/typescript/module/ui/components/ActiveAccountBanner.d.ts.map +1 -0
- package/lib/typescript/module/ui/context/OxyContext.d.ts +33 -5
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/module/ui/index.d.ts +5 -0
- package/lib/typescript/module/ui/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/navigation/routes.d.ts +1 -1
- package/lib/typescript/module/ui/navigation/routes.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts +10 -0
- package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts.map +1 -0
- package/lib/typescript/module/ui/screens/AccountSettingsScreen.d.ts +12 -0
- package/lib/typescript/module/ui/screens/AccountSettingsScreen.d.ts.map +1 -0
- package/lib/typescript/module/ui/screens/AccountSwitcherScreen.d.ts +11 -0
- package/lib/typescript/module/ui/screens/AccountSwitcherScreen.d.ts.map +1 -0
- package/lib/typescript/module/ui/screens/CreateAccountScreen.d.ts +11 -0
- package/lib/typescript/module/ui/screens/CreateAccountScreen.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/index.ts +18 -4
- package/src/ui/components/AccountMenuButton.tsx +25 -10
- package/src/ui/components/AccountSwitcher.tsx +763 -0
- package/src/ui/components/ActiveAccountBanner.tsx +130 -0
- package/src/ui/context/OxyContext.tsx +122 -37
- package/src/ui/index.ts +5 -0
- package/src/ui/navigation/routes.ts +8 -2
- package/src/ui/screens/AccountMembersScreen.tsx +481 -0
- package/src/ui/screens/AccountSettingsScreen.tsx +261 -0
- package/src/ui/screens/AccountSwitcherScreen.tsx +43 -0
- package/src/ui/screens/{CreateManagedAccountScreen.tsx → CreateAccountScreen.tsx} +140 -22
- package/src/ui/screens/ManageAccountScreen.tsx +19 -19
- package/lib/commonjs/ui/components/ActingAsBanner.js +0 -140
- package/lib/commonjs/ui/components/ActingAsBanner.js.map +0 -1
- package/lib/commonjs/ui/screens/CreateManagedAccountScreen.js.map +0 -1
- package/lib/module/ui/components/ActingAsBanner.js +0 -137
- package/lib/module/ui/components/ActingAsBanner.js.map +0 -1
- package/lib/module/ui/screens/CreateManagedAccountScreen.js.map +0 -1
- package/lib/typescript/commonjs/ui/components/ActingAsBanner.d.ts.map +0 -1
- package/lib/typescript/commonjs/ui/screens/CreateManagedAccountScreen.d.ts +0 -5
- package/lib/typescript/commonjs/ui/screens/CreateManagedAccountScreen.d.ts.map +0 -1
- package/lib/typescript/module/ui/components/ActingAsBanner.d.ts.map +0 -1
- package/lib/typescript/module/ui/screens/CreateManagedAccountScreen.d.ts +0 -5
- package/lib/typescript/module/ui/screens/CreateManagedAccountScreen.d.ts.map +0 -1
- package/src/ui/components/ActingAsBanner.tsx +0 -129
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
View,
|
|
5
|
+
ScrollView,
|
|
6
|
+
ActivityIndicator,
|
|
7
|
+
Platform,
|
|
8
|
+
KeyboardAvoidingView,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
11
|
+
import { toast, Dialog, useDialogControl } from '@oxyhq/bloom';
|
|
12
|
+
import { useTheme } from '@oxyhq/bloom/theme';
|
|
13
|
+
import { H1, Text } from '@oxyhq/bloom/typography';
|
|
14
|
+
import { Button } from '@oxyhq/bloom/button';
|
|
15
|
+
import { TextField, TextFieldInput } from '@oxyhq/bloom/text-field';
|
|
16
|
+
import { SettingsListGroup, SettingsListItem } from '@oxyhq/bloom/settings-list';
|
|
17
|
+
import type { UpdateAccountInput } from '@oxyhq/core';
|
|
18
|
+
import type { BaseScreenProps } from '../types/navigation';
|
|
19
|
+
import Header from '../components/Header';
|
|
20
|
+
import { SettingsIcon } from '../components/SettingsIcon';
|
|
21
|
+
import { useOxy } from '../context/OxyContext';
|
|
22
|
+
import { useI18n } from '../hooks/useI18n';
|
|
23
|
+
|
|
24
|
+
const DISPLAY_NAME_MAX = 50;
|
|
25
|
+
const BIO_MAX = 160;
|
|
26
|
+
|
|
27
|
+
const errorMessage = (error: unknown, fallback: string): string =>
|
|
28
|
+
error instanceof Error && error.message ? error.message : fallback;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Per-account settings: edit the account's profile, jump to member management,
|
|
32
|
+
* and archive the account (danger zone). Receives the target `accountId` via
|
|
33
|
+
* navigation props. Self-surface for the caller's own personal account lives in
|
|
34
|
+
* {@link ManageAccountScreen}; this screen manages a non-personal account in the
|
|
35
|
+
* graph.
|
|
36
|
+
*/
|
|
37
|
+
const AccountSettingsScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, navigate, accountId }) => {
|
|
38
|
+
const bloomTheme = useTheme();
|
|
39
|
+
const colors = bloomTheme.colors;
|
|
40
|
+
const { t } = useI18n();
|
|
41
|
+
const { oxyServices, canUsePrivateApi, actingAs, setActingAs } = useOxy();
|
|
42
|
+
const queryClient = useQueryClient();
|
|
43
|
+
|
|
44
|
+
const id = typeof accountId === 'string' ? accountId : '';
|
|
45
|
+
|
|
46
|
+
const accountQuery = useQuery({
|
|
47
|
+
queryKey: ['accounts', 'detail', id],
|
|
48
|
+
queryFn: () => oxyServices.getAccount(id),
|
|
49
|
+
enabled: canUsePrivateApi && id.length > 0,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const node = accountQuery.data ?? null;
|
|
53
|
+
const permissions = node?.callerMembership?.permissions ?? [];
|
|
54
|
+
const isImplicitOwner = node ? node.relationship !== 'member' : false;
|
|
55
|
+
const can = useCallback(
|
|
56
|
+
(permission: string): boolean => isImplicitOwner || permissions.includes(permission),
|
|
57
|
+
[isImplicitOwner, permissions],
|
|
58
|
+
);
|
|
59
|
+
const canUpdate = can('account:update');
|
|
60
|
+
const canViewMembers = can('members:read');
|
|
61
|
+
const canArchive = can('account:delete');
|
|
62
|
+
|
|
63
|
+
// Editable fields, seeded lazily from the loaded account once.
|
|
64
|
+
const [seeded, setSeeded] = useState(false);
|
|
65
|
+
const [displayName, setDisplayName] = useState('');
|
|
66
|
+
const [bio, setBio] = useState('');
|
|
67
|
+
|
|
68
|
+
// Seed the form from the account during render (no useEffect): the first time
|
|
69
|
+
// the query resolves we capture its values into local edit state.
|
|
70
|
+
if (node && !seeded) {
|
|
71
|
+
const first = node.account?.name?.first ?? '';
|
|
72
|
+
const last = node.account?.name?.last ?? '';
|
|
73
|
+
setDisplayName([first, last].filter(Boolean).join(' '));
|
|
74
|
+
setBio(node.account?.bio ?? '');
|
|
75
|
+
setSeeded(true);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const updateMutation = useMutation({
|
|
79
|
+
mutationKey: ['accounts', 'update', id],
|
|
80
|
+
mutationFn: (input: UpdateAccountInput) => oxyServices.updateAccount(id, input),
|
|
81
|
+
onSuccess: () => {
|
|
82
|
+
queryClient.invalidateQueries({ queryKey: ['accounts', 'detail', id] });
|
|
83
|
+
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
|
84
|
+
toast.success(t('accounts.settings.toasts.saved') || 'Account updated');
|
|
85
|
+
},
|
|
86
|
+
onError: (error) => {
|
|
87
|
+
toast.error(errorMessage(error, t('accounts.settings.toasts.saveFailed') || 'Failed to update account'));
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const archiveMutation = useMutation({
|
|
92
|
+
mutationKey: ['accounts', 'archive', id],
|
|
93
|
+
mutationFn: () => oxyServices.archiveAccount(id),
|
|
94
|
+
onSuccess: () => {
|
|
95
|
+
// If this was the active account, drop back to the personal account.
|
|
96
|
+
if (actingAs === id) {
|
|
97
|
+
setActingAs(null);
|
|
98
|
+
}
|
|
99
|
+
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
|
100
|
+
toast.success(t('accounts.settings.toasts.archived') || 'Account archived');
|
|
101
|
+
(onClose ?? goBack)?.();
|
|
102
|
+
},
|
|
103
|
+
onError: (error) => {
|
|
104
|
+
toast.error(errorMessage(error, t('accounts.settings.toasts.archiveFailed') || 'Failed to archive account'));
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const archiveDialog = useDialogControl();
|
|
109
|
+
|
|
110
|
+
const handleSave = useCallback(() => {
|
|
111
|
+
const trimmed = displayName.trim();
|
|
112
|
+
const nameParts = trimmed.split(/\s+/).filter(Boolean);
|
|
113
|
+
const first = nameParts[0] || '';
|
|
114
|
+
const last = nameParts.length > 1 ? nameParts.slice(1).join(' ') : undefined;
|
|
115
|
+
updateMutation.mutate({
|
|
116
|
+
name: { first, last },
|
|
117
|
+
bio: bio.trim() ? bio.trim() : null,
|
|
118
|
+
});
|
|
119
|
+
}, [displayName, bio, updateMutation]);
|
|
120
|
+
|
|
121
|
+
const title = t('accounts.settings.title') || 'Account settings';
|
|
122
|
+
|
|
123
|
+
const accountHandle = useMemo(() => {
|
|
124
|
+
const username = node?.account?.username;
|
|
125
|
+
return username ? `@${username}` : '';
|
|
126
|
+
}, [node?.account?.username]);
|
|
127
|
+
|
|
128
|
+
if (!id) {
|
|
129
|
+
return (
|
|
130
|
+
<View className="flex-1 bg-bg">
|
|
131
|
+
<Header title={title} onBack={goBack} onClose={onClose} showBackButton showCloseButton elevation="subtle" />
|
|
132
|
+
<View className="flex-1 items-center justify-center px-screen-margin">
|
|
133
|
+
<Text className="text-body font-body text-text-secondary text-center">
|
|
134
|
+
{t('accounts.settings.errors.missingAccount') || 'No account selected.'}
|
|
135
|
+
</Text>
|
|
136
|
+
</View>
|
|
137
|
+
</View>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<View className="flex-1 bg-bg">
|
|
143
|
+
<Header title={title} onBack={goBack} onClose={onClose} showBackButton showCloseButton elevation="subtle" />
|
|
144
|
+
|
|
145
|
+
<KeyboardAvoidingView
|
|
146
|
+
className="flex-1"
|
|
147
|
+
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
148
|
+
keyboardVerticalOffset={Platform.OS === 'ios' ? 88 : 0}
|
|
149
|
+
>
|
|
150
|
+
<ScrollView
|
|
151
|
+
className="flex-1"
|
|
152
|
+
contentContainerClassName="px-screen-margin pt-space-24 pb-space-32 gap-space-24"
|
|
153
|
+
showsVerticalScrollIndicator={false}
|
|
154
|
+
keyboardShouldPersistTaps="handled"
|
|
155
|
+
>
|
|
156
|
+
{accountQuery.isLoading ? (
|
|
157
|
+
<View className="items-center py-space-32">
|
|
158
|
+
<ActivityIndicator color={colors.primary} />
|
|
159
|
+
</View>
|
|
160
|
+
) : (
|
|
161
|
+
<>
|
|
162
|
+
<View className="gap-space-8">
|
|
163
|
+
<H1 className="text-headerBold font-headerBold text-text" numberOfLines={1}>
|
|
164
|
+
{accountHandle || title}
|
|
165
|
+
</H1>
|
|
166
|
+
<Text className="text-body font-body text-text-secondary">
|
|
167
|
+
{t('accounts.settings.subtitle') || 'Manage this account’s profile, members, and access.'}
|
|
168
|
+
</Text>
|
|
169
|
+
</View>
|
|
170
|
+
|
|
171
|
+
{/* Profile edit */}
|
|
172
|
+
{canUpdate ? (
|
|
173
|
+
<View className="gap-space-16 p-space-16 rounded-radius-20 bg-fill">
|
|
174
|
+
<TextField>
|
|
175
|
+
<TextFieldInput
|
|
176
|
+
floatingLabel
|
|
177
|
+
label={t('accounts.settings.displayName.label') || 'Display name'}
|
|
178
|
+
value={displayName}
|
|
179
|
+
onChangeText={setDisplayName}
|
|
180
|
+
maxLength={DISPLAY_NAME_MAX}
|
|
181
|
+
/>
|
|
182
|
+
</TextField>
|
|
183
|
+
<View className="gap-space-4">
|
|
184
|
+
<TextField>
|
|
185
|
+
<TextFieldInput
|
|
186
|
+
floatingLabel
|
|
187
|
+
label={t('accounts.settings.bio.label') || 'Bio (optional)'}
|
|
188
|
+
value={bio}
|
|
189
|
+
onChangeText={setBio}
|
|
190
|
+
maxLength={BIO_MAX}
|
|
191
|
+
multiline
|
|
192
|
+
numberOfLines={3}
|
|
193
|
+
textAlignVertical="top"
|
|
194
|
+
/>
|
|
195
|
+
</TextField>
|
|
196
|
+
<Text className="text-caption font-caption text-text-tertiary px-space-4 text-right">
|
|
197
|
+
{bio.length}/{BIO_MAX}
|
|
198
|
+
</Text>
|
|
199
|
+
</View>
|
|
200
|
+
<Button
|
|
201
|
+
variant="primary"
|
|
202
|
+
onPress={handleSave}
|
|
203
|
+
disabled={updateMutation.isPending || !displayName.trim()}
|
|
204
|
+
loading={updateMutation.isPending}
|
|
205
|
+
accessibilityLabel={t('accounts.settings.save') || 'Save changes'}
|
|
206
|
+
className="w-full"
|
|
207
|
+
>
|
|
208
|
+
{t('accounts.settings.save') || 'Save changes'}
|
|
209
|
+
</Button>
|
|
210
|
+
</View>
|
|
211
|
+
) : null}
|
|
212
|
+
|
|
213
|
+
{/* Members entry */}
|
|
214
|
+
{canViewMembers ? (
|
|
215
|
+
<SettingsListGroup title={t('accounts.settings.sections.access') || 'Access'}>
|
|
216
|
+
<SettingsListItem
|
|
217
|
+
icon={<SettingsIcon name="account-multiple" color={colors.info} />}
|
|
218
|
+
title={t('accounts.members.title') || 'Members'}
|
|
219
|
+
description={t('accounts.settings.members.subtitle') || 'Invite people and manage roles'}
|
|
220
|
+
onPress={() => navigate?.('AccountMembers', { accountId: id })}
|
|
221
|
+
/>
|
|
222
|
+
</SettingsListGroup>
|
|
223
|
+
) : null}
|
|
224
|
+
|
|
225
|
+
{/* Danger zone */}
|
|
226
|
+
{canArchive ? (
|
|
227
|
+
<SettingsListGroup title={t('accounts.settings.sections.dangerZone') || 'Danger zone'}>
|
|
228
|
+
<SettingsListItem
|
|
229
|
+
icon={<SettingsIcon name="archive-arrow-down" color={colors.error} />}
|
|
230
|
+
title={t('accounts.settings.archive.title') || 'Archive account'}
|
|
231
|
+
description={t('accounts.settings.archive.subtitle') || 'Deactivate this account'}
|
|
232
|
+
onPress={() => archiveDialog.open()}
|
|
233
|
+
/>
|
|
234
|
+
</SettingsListGroup>
|
|
235
|
+
) : null}
|
|
236
|
+
</>
|
|
237
|
+
)}
|
|
238
|
+
</ScrollView>
|
|
239
|
+
</KeyboardAvoidingView>
|
|
240
|
+
|
|
241
|
+
<Dialog
|
|
242
|
+
control={archiveDialog}
|
|
243
|
+
title={t('accounts.settings.archive.confirmTitle') || 'Archive account'}
|
|
244
|
+
description={
|
|
245
|
+
t('accounts.settings.archive.confirmDescription')
|
|
246
|
+
|| 'Archive this account? It will be deactivated and its members will lose access.'
|
|
247
|
+
}
|
|
248
|
+
actions={[
|
|
249
|
+
{
|
|
250
|
+
label: t('accounts.settings.archive.title') || 'Archive account',
|
|
251
|
+
color: 'destructive',
|
|
252
|
+
onPress: () => archiveMutation.mutate(),
|
|
253
|
+
},
|
|
254
|
+
{ label: t('common.cancel') || 'Cancel', color: 'cancel' },
|
|
255
|
+
]}
|
|
256
|
+
/>
|
|
257
|
+
</View>
|
|
258
|
+
);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
export default AccountSettingsScreen;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import { useCallback } from 'react';
|
|
3
|
+
import { View } from 'react-native';
|
|
4
|
+
import type { BaseScreenProps } from '../types/navigation';
|
|
5
|
+
import Header from '../components/Header';
|
|
6
|
+
import { AccountSwitcherView } from '../components/AccountSwitcher';
|
|
7
|
+
import { useI18n } from '../hooks/useI18n';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Bottom-sheet route wrapper around {@link AccountSwitcherView}. Used when the
|
|
11
|
+
* switcher is opened as a sheet (e.g. from the `ActiveAccountBanner` cue or the
|
|
12
|
+
* "Your accounts" entry in ManageAccount) rather than as the header-chip
|
|
13
|
+
* popover (which uses the `AccountSwitcher` modal directly).
|
|
14
|
+
*/
|
|
15
|
+
const AccountSwitcherScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, navigate }) => {
|
|
16
|
+
const { t } = useI18n();
|
|
17
|
+
|
|
18
|
+
const close = useCallback(() => {
|
|
19
|
+
(onClose ?? goBack)?.();
|
|
20
|
+
}, [onClose, goBack]);
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<View className="flex-1 bg-bg">
|
|
24
|
+
<Header
|
|
25
|
+
title={t('accountSwitcher.title') || 'Switch account'}
|
|
26
|
+
onBack={goBack}
|
|
27
|
+
onClose={onClose}
|
|
28
|
+
showBackButton={!!goBack}
|
|
29
|
+
showCloseButton={!!onClose}
|
|
30
|
+
elevation="subtle"
|
|
31
|
+
/>
|
|
32
|
+
<AccountSwitcherView
|
|
33
|
+
onClose={close}
|
|
34
|
+
onAddAccount={() => navigate?.('OxyAuth')}
|
|
35
|
+
onNavigateManage={() => navigate?.('ManageAccount')}
|
|
36
|
+
onCreateAccount={() => navigate?.('CreateAccount')}
|
|
37
|
+
onOpenAccountSettings={(accountId) => navigate?.('AccountSettings', { accountId })}
|
|
38
|
+
/>
|
|
39
|
+
</View>
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export default AccountSwitcherScreen;
|
|
@@ -6,8 +6,10 @@ import {
|
|
|
6
6
|
ActivityIndicator,
|
|
7
7
|
ScrollView,
|
|
8
8
|
KeyboardAvoidingView,
|
|
9
|
+
TouchableOpacity,
|
|
9
10
|
} from 'react-native';
|
|
10
11
|
import { Ionicons } from '@expo/vector-icons';
|
|
12
|
+
import type { AccountKind, CreateAccountInput } from '@oxyhq/core';
|
|
11
13
|
import type { BaseScreenProps } from '../types/navigation';
|
|
12
14
|
import Header from '../components/Header';
|
|
13
15
|
import { useI18n } from '../hooks/useI18n';
|
|
@@ -20,20 +22,74 @@ import { toast } from '@oxyhq/bloom';
|
|
|
20
22
|
|
|
21
23
|
type UsernameStatus = 'idle' | 'checking' | 'available' | 'taken' | 'invalid';
|
|
22
24
|
|
|
25
|
+
/** Kind of account this screen can create. `personal` is a signup-minted root and is never created here. */
|
|
26
|
+
type CreatableAccountKind = Exclude<AccountKind, 'personal'>;
|
|
27
|
+
|
|
23
28
|
const USERNAME_REGEX = /^[a-zA-Z0-9_-]{3,30}$/;
|
|
24
29
|
const DEBOUNCE_MS = 400;
|
|
25
30
|
const USERNAME_MAX = 30;
|
|
26
31
|
const DISPLAY_NAME_MAX = 50;
|
|
27
32
|
const BIO_MAX = 160;
|
|
28
33
|
|
|
29
|
-
|
|
34
|
+
interface KindOption {
|
|
35
|
+
value: CreatableAccountKind;
|
|
36
|
+
icon: React.ComponentProps<typeof Ionicons>['name'];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The creatable account kinds, matching the API `createAccountSchema` enum.
|
|
40
|
+
// Order places the most common choice (a project / persona) first.
|
|
41
|
+
const KIND_OPTIONS: KindOption[] = [
|
|
42
|
+
{ value: 'project', icon: 'cube-outline' },
|
|
43
|
+
{ value: 'organization', icon: 'business-outline' },
|
|
44
|
+
{ value: 'bot', icon: 'hardware-chip-outline' },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const kindLabel = (
|
|
48
|
+
t: (key: string, vars?: Record<string, string | number>) => string,
|
|
49
|
+
kind: CreatableAccountKind,
|
|
50
|
+
): string => {
|
|
51
|
+
switch (kind) {
|
|
52
|
+
case 'organization':
|
|
53
|
+
return t('accounts.kinds.organization.label') || 'Organization';
|
|
54
|
+
case 'bot':
|
|
55
|
+
return t('accounts.kinds.bot.label') || 'Bot';
|
|
56
|
+
default:
|
|
57
|
+
return t('accounts.kinds.project.label') || 'Project';
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const kindDescription = (
|
|
62
|
+
t: (key: string, vars?: Record<string, string | number>) => string,
|
|
63
|
+
kind: CreatableAccountKind,
|
|
64
|
+
): string => {
|
|
65
|
+
switch (kind) {
|
|
66
|
+
case 'organization':
|
|
67
|
+
return t('accounts.kinds.organization.description') || 'A shared team account with members';
|
|
68
|
+
case 'bot':
|
|
69
|
+
return t('accounts.kinds.bot.description') || 'A programmatic account with service credentials';
|
|
70
|
+
default:
|
|
71
|
+
return t('accounts.kinds.project.description') || 'A separate account you control';
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Create a new account in the unified account graph (an organization, project,
|
|
77
|
+
* or bot). The caller becomes its owner. Optionally nested under a parent
|
|
78
|
+
* account via the `parentAccountId` prop. NOT the cryptographic Commons/DID
|
|
79
|
+
* "identity" — that is a separate concept.
|
|
80
|
+
*/
|
|
81
|
+
const CreateAccountScreen: React.FC<BaseScreenProps> = ({
|
|
30
82
|
onClose,
|
|
31
83
|
goBack,
|
|
84
|
+
parentAccountId,
|
|
32
85
|
}) => {
|
|
33
86
|
const bloomTheme = useTheme();
|
|
34
|
-
const { oxyServices,
|
|
87
|
+
const { oxyServices, createAccount, setActingAs } = useOxy();
|
|
35
88
|
const { t } = useI18n();
|
|
36
89
|
|
|
90
|
+
const parentId = typeof parentAccountId === 'string' ? parentAccountId : undefined;
|
|
91
|
+
|
|
92
|
+
const [kind, setKind] = useState<CreatableAccountKind>('project');
|
|
37
93
|
const [username, setUsername] = useState('');
|
|
38
94
|
const [displayName, setDisplayName] = useState('');
|
|
39
95
|
const [bio, setBio] = useState('');
|
|
@@ -51,13 +107,19 @@ const CreateManagedAccountScreen: React.FC<BaseScreenProps> = ({
|
|
|
51
107
|
|
|
52
108
|
if (!value || value.length < 3) {
|
|
53
109
|
setUsernameStatus(value.length > 0 ? 'invalid' : 'idle');
|
|
54
|
-
setUsernameMessage(
|
|
110
|
+
setUsernameMessage(
|
|
111
|
+
value.length > 0
|
|
112
|
+
? (t('accounts.create.username.tooShort') || 'Username must be at least 3 characters')
|
|
113
|
+
: '',
|
|
114
|
+
);
|
|
55
115
|
return;
|
|
56
116
|
}
|
|
57
117
|
|
|
58
118
|
if (!USERNAME_REGEX.test(value)) {
|
|
59
119
|
setUsernameStatus('invalid');
|
|
60
|
-
setUsernameMessage(
|
|
120
|
+
setUsernameMessage(
|
|
121
|
+
t('accounts.create.username.invalidChars') || 'Only letters, numbers, hyphens, and underscores',
|
|
122
|
+
);
|
|
61
123
|
return;
|
|
62
124
|
}
|
|
63
125
|
|
|
@@ -68,13 +130,18 @@ const CreateManagedAccountScreen: React.FC<BaseScreenProps> = ({
|
|
|
68
130
|
try {
|
|
69
131
|
const result = await oxyServices.checkUsernameAvailability(value);
|
|
70
132
|
setUsernameStatus(result.available ? 'available' : 'taken');
|
|
71
|
-
setUsernameMessage(
|
|
133
|
+
setUsernameMessage(
|
|
134
|
+
result.message
|
|
135
|
+
|| (result.available
|
|
136
|
+
? (t('accounts.create.username.available') || 'Username is available')
|
|
137
|
+
: (t('accounts.create.username.taken') || 'Username is taken')),
|
|
138
|
+
);
|
|
72
139
|
} catch {
|
|
73
140
|
setUsernameStatus('idle');
|
|
74
|
-
setUsernameMessage('Could not check availability');
|
|
141
|
+
setUsernameMessage(t('accounts.create.username.checkFailed') || 'Could not check availability');
|
|
75
142
|
}
|
|
76
143
|
}, DEBOUNCE_MS);
|
|
77
|
-
}, [oxyServices]);
|
|
144
|
+
}, [oxyServices, t]);
|
|
78
145
|
|
|
79
146
|
const handleUsernameChange = useCallback((value: string) => {
|
|
80
147
|
const cleaned = value.toLowerCase().replace(/[^a-z0-9_-]/g, '');
|
|
@@ -103,27 +170,32 @@ const CreateManagedAccountScreen: React.FC<BaseScreenProps> = ({
|
|
|
103
170
|
const firstName = nameParts[0] || '';
|
|
104
171
|
const lastName = nameParts.length > 1 ? nameParts.slice(1).join(' ') : undefined;
|
|
105
172
|
|
|
106
|
-
const
|
|
173
|
+
const input: CreateAccountInput = {
|
|
174
|
+
kind,
|
|
107
175
|
username,
|
|
108
176
|
name: { first: firstName, last: lastName },
|
|
109
177
|
bio: bio.trim() || undefined,
|
|
110
|
-
|
|
178
|
+
...(parentId ? { parentAccountId: parentId } : null),
|
|
179
|
+
};
|
|
180
|
+
const account = await createAccount(input);
|
|
111
181
|
|
|
112
|
-
toast.success('
|
|
182
|
+
toast.success(t('accounts.create.toasts.success') || 'Account created');
|
|
113
183
|
|
|
114
|
-
// Switch to the new
|
|
184
|
+
// Switch to the new account
|
|
115
185
|
if (account.accountId) {
|
|
116
186
|
setActingAs(account.accountId);
|
|
117
187
|
}
|
|
118
188
|
|
|
119
189
|
onClose?.();
|
|
120
190
|
} catch (error) {
|
|
121
|
-
const message = error instanceof Error
|
|
191
|
+
const message = error instanceof Error
|
|
192
|
+
? error.message
|
|
193
|
+
: (t('accounts.create.toasts.failed') || 'Failed to create account');
|
|
122
194
|
toast.error(message);
|
|
123
195
|
} finally {
|
|
124
196
|
setIsCreating(false);
|
|
125
197
|
}
|
|
126
|
-
}, [canCreate, username, displayName, bio,
|
|
198
|
+
}, [canCreate, kind, username, displayName, bio, parentId, createAccount, setActingAs, onClose, t]);
|
|
127
199
|
|
|
128
200
|
// Status icon + color shown alongside the username field message
|
|
129
201
|
const usernameIsInvalid = usernameStatus === 'taken' || usernameStatus === 'invalid';
|
|
@@ -133,10 +205,12 @@ const CreateManagedAccountScreen: React.FC<BaseScreenProps> = ({
|
|
|
133
205
|
? bloomTheme.colors.negative
|
|
134
206
|
: bloomTheme.colors.textSecondary;
|
|
135
207
|
|
|
208
|
+
const title = t('accounts.create.title') || 'Create account';
|
|
209
|
+
|
|
136
210
|
return (
|
|
137
211
|
<View className="flex-1 bg-bg">
|
|
138
212
|
<Header
|
|
139
|
-
title=
|
|
213
|
+
title={title}
|
|
140
214
|
onBack={goBack}
|
|
141
215
|
onClose={onClose}
|
|
142
216
|
showBackButton={true}
|
|
@@ -158,13 +232,57 @@ const CreateManagedAccountScreen: React.FC<BaseScreenProps> = ({
|
|
|
158
232
|
{/* Big Title */}
|
|
159
233
|
<View className="gap-space-8">
|
|
160
234
|
<H1 className="text-headerBold font-headerBold text-text">
|
|
161
|
-
|
|
235
|
+
{title}
|
|
162
236
|
</H1>
|
|
163
237
|
<Text className="text-body font-body text-text-secondary">
|
|
164
|
-
|
|
238
|
+
{t('accounts.create.subtitle')
|
|
239
|
+
|| 'Create an account you control. It will have its own profile, members, and apps.'}
|
|
165
240
|
</Text>
|
|
166
241
|
</View>
|
|
167
242
|
|
|
243
|
+
{/* Kind picker */}
|
|
244
|
+
<View className="gap-space-8">
|
|
245
|
+
{KIND_OPTIONS.map((option) => {
|
|
246
|
+
const selected = option.value === kind;
|
|
247
|
+
return (
|
|
248
|
+
<TouchableOpacity
|
|
249
|
+
key={option.value}
|
|
250
|
+
accessibilityRole="radio"
|
|
251
|
+
accessibilityState={{ selected }}
|
|
252
|
+
accessibilityLabel={kindLabel(t, option.value)}
|
|
253
|
+
onPress={() => setKind(option.value)}
|
|
254
|
+
activeOpacity={0.7}
|
|
255
|
+
className="flex-row items-center gap-space-12 p-space-16 rounded-radius-20"
|
|
256
|
+
style={{
|
|
257
|
+
backgroundColor: selected ? bloomTheme.colors.primarySubtle : bloomTheme.colors.card,
|
|
258
|
+
borderWidth: 1,
|
|
259
|
+
borderColor: selected ? bloomTheme.colors.primary : bloomTheme.colors.border,
|
|
260
|
+
}}
|
|
261
|
+
>
|
|
262
|
+
<Ionicons
|
|
263
|
+
name={option.icon}
|
|
264
|
+
size={22}
|
|
265
|
+
color={selected ? bloomTheme.colors.primary : bloomTheme.colors.icon}
|
|
266
|
+
/>
|
|
267
|
+
<View className="flex-1">
|
|
268
|
+
<Text
|
|
269
|
+
className="text-body font-bodyBold"
|
|
270
|
+
style={{ color: selected ? bloomTheme.colors.primary : bloomTheme.colors.text }}
|
|
271
|
+
>
|
|
272
|
+
{kindLabel(t, option.value)}
|
|
273
|
+
</Text>
|
|
274
|
+
<Text className="text-caption font-caption text-text-secondary">
|
|
275
|
+
{kindDescription(t, option.value)}
|
|
276
|
+
</Text>
|
|
277
|
+
</View>
|
|
278
|
+
{selected ? (
|
|
279
|
+
<Ionicons name="checkmark-circle" size={20} color={bloomTheme.colors.primary} />
|
|
280
|
+
) : null}
|
|
281
|
+
</TouchableOpacity>
|
|
282
|
+
);
|
|
283
|
+
})}
|
|
284
|
+
</View>
|
|
285
|
+
|
|
168
286
|
{/* Form Content */}
|
|
169
287
|
<View className="gap-space-16 p-space-16 rounded-radius-20 bg-fill">
|
|
170
288
|
{/* Username */}
|
|
@@ -172,7 +290,7 @@ const CreateManagedAccountScreen: React.FC<BaseScreenProps> = ({
|
|
|
172
290
|
<TextField isInvalid={usernameIsInvalid}>
|
|
173
291
|
<TextFieldInput
|
|
174
292
|
floatingLabel
|
|
175
|
-
label=
|
|
293
|
+
label={t('accounts.create.username.label') || 'Username'}
|
|
176
294
|
value={username}
|
|
177
295
|
onChangeText={handleUsernameChange}
|
|
178
296
|
isInvalid={usernameIsInvalid}
|
|
@@ -204,7 +322,7 @@ const CreateManagedAccountScreen: React.FC<BaseScreenProps> = ({
|
|
|
204
322
|
<TextField>
|
|
205
323
|
<TextFieldInput
|
|
206
324
|
floatingLabel
|
|
207
|
-
label=
|
|
325
|
+
label={t('accounts.create.displayName.label') || 'Display name'}
|
|
208
326
|
value={displayName}
|
|
209
327
|
onChangeText={setDisplayName}
|
|
210
328
|
maxLength={DISPLAY_NAME_MAX}
|
|
@@ -216,7 +334,7 @@ const CreateManagedAccountScreen: React.FC<BaseScreenProps> = ({
|
|
|
216
334
|
<TextField>
|
|
217
335
|
<TextFieldInput
|
|
218
336
|
floatingLabel
|
|
219
|
-
label=
|
|
337
|
+
label={t('accounts.create.bio.label') || 'Bio (optional)'}
|
|
220
338
|
value={bio}
|
|
221
339
|
onChangeText={setBio}
|
|
222
340
|
maxLength={BIO_MAX}
|
|
@@ -237,10 +355,10 @@ const CreateManagedAccountScreen: React.FC<BaseScreenProps> = ({
|
|
|
237
355
|
onPress={handleCreate}
|
|
238
356
|
disabled={!canCreate}
|
|
239
357
|
loading={isCreating}
|
|
240
|
-
accessibilityLabel=
|
|
358
|
+
accessibilityLabel={title}
|
|
241
359
|
className="w-full"
|
|
242
360
|
>
|
|
243
|
-
|
|
361
|
+
{title}
|
|
244
362
|
</Button>
|
|
245
363
|
</ScrollView>
|
|
246
364
|
</KeyboardAvoidingView>
|
|
@@ -248,4 +366,4 @@ const CreateManagedAccountScreen: React.FC<BaseScreenProps> = ({
|
|
|
248
366
|
);
|
|
249
367
|
};
|
|
250
368
|
|
|
251
|
-
export default
|
|
369
|
+
export default CreateAccountScreen;
|
|
@@ -94,7 +94,7 @@ const ManageAccountScreen: React.FC<BaseScreenProps> = ({
|
|
|
94
94
|
activeSessionId,
|
|
95
95
|
logout,
|
|
96
96
|
openAvatarPicker,
|
|
97
|
-
|
|
97
|
+
accounts,
|
|
98
98
|
} = useOxy();
|
|
99
99
|
|
|
100
100
|
const { data: userFromQuery, isLoading: userLoading } = useCurrentUser({
|
|
@@ -667,12 +667,12 @@ const ManageAccountScreen: React.FC<BaseScreenProps> = ({
|
|
|
667
667
|
/>
|
|
668
668
|
</SettingsListGroup>
|
|
669
669
|
|
|
670
|
-
{/*
|
|
671
|
-
{
|
|
670
|
+
{/* Accounts (unified account graph) */}
|
|
671
|
+
{accounts.length > 0 || isAuthenticated ? (
|
|
672
672
|
<SettingsListGroup
|
|
673
673
|
title={
|
|
674
|
-
t('accountCenter.sections.
|
|
675
|
-
|| '
|
|
674
|
+
t('accountCenter.sections.accounts')
|
|
675
|
+
|| 'Accounts'
|
|
676
676
|
}
|
|
677
677
|
>
|
|
678
678
|
<SettingsListItem
|
|
@@ -683,23 +683,23 @@ const ManageAccountScreen: React.FC<BaseScreenProps> = ({
|
|
|
683
683
|
/>
|
|
684
684
|
}
|
|
685
685
|
title={
|
|
686
|
-
t('
|
|
687
|
-
|| '
|
|
686
|
+
t('accounts.manage.switch.title')
|
|
687
|
+
|| 'Switch account'
|
|
688
688
|
}
|
|
689
689
|
description={
|
|
690
|
-
|
|
690
|
+
accounts.length > 0
|
|
691
691
|
? (
|
|
692
|
-
t('
|
|
693
|
-
count:
|
|
692
|
+
t('accounts.manage.switch.count', {
|
|
693
|
+
count: accounts.length,
|
|
694
694
|
})
|
|
695
|
-
|| `${
|
|
695
|
+
|| `${accounts.length} ${accounts.length === 1 ? 'account' : 'accounts'}`
|
|
696
696
|
)
|
|
697
697
|
: (
|
|
698
|
-
t('
|
|
699
|
-
|| '
|
|
698
|
+
t('accounts.manage.switch.empty')
|
|
699
|
+
|| 'Accounts you own or share'
|
|
700
700
|
)
|
|
701
701
|
}
|
|
702
|
-
onPress={() => navigate?.('
|
|
702
|
+
onPress={() => navigate?.('AccountSwitcher')}
|
|
703
703
|
/>
|
|
704
704
|
<SettingsListItem
|
|
705
705
|
icon={
|
|
@@ -709,14 +709,14 @@ const ManageAccountScreen: React.FC<BaseScreenProps> = ({
|
|
|
709
709
|
/>
|
|
710
710
|
}
|
|
711
711
|
title={
|
|
712
|
-
t('
|
|
713
|
-
|| 'Create
|
|
712
|
+
t('accounts.create.title')
|
|
713
|
+
|| 'Create account'
|
|
714
714
|
}
|
|
715
715
|
description={
|
|
716
|
-
t('
|
|
717
|
-
|| 'Add
|
|
716
|
+
t('accounts.manage.create.subtitle')
|
|
717
|
+
|| 'Add an organization, project, or bot'
|
|
718
718
|
}
|
|
719
|
-
onPress={() => navigate?.('
|
|
719
|
+
onPress={() => navigate?.('CreateAccount')}
|
|
720
720
|
/>
|
|
721
721
|
</SettingsListGroup>
|
|
722
722
|
) : null}
|