@oxyhq/services 17.0.0 → 18.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.
- package/README.md +77 -98
- package/lib/commonjs/index.js +19 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/components/OxyAccountDialog.js +62 -84
- package/lib/commonjs/ui/components/OxyAccountDialog.js.map +1 -1
- package/lib/commonjs/ui/components/OxyConsentScreen.js +461 -0
- package/lib/commonjs/ui/components/OxyConsentScreen.js.map +1 -0
- package/lib/commonjs/ui/components/OxyProvider.js +3 -1
- package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
- package/lib/commonjs/ui/components/OxySignInButton.js +176 -7
- package/lib/commonjs/ui/components/OxySignInButton.js.map +1 -1
- package/lib/commonjs/ui/components/oauthNavigation.js +73 -0
- package/lib/commonjs/ui/components/oauthNavigation.js.map +1 -0
- package/lib/commonjs/ui/context/OxyContext.js +16 -2
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/module/index.js +2 -2
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/components/OxyAccountDialog.js +63 -85
- package/lib/module/ui/components/OxyAccountDialog.js.map +1 -1
- package/lib/module/ui/components/OxyConsentScreen.js +454 -0
- package/lib/module/ui/components/OxyConsentScreen.js.map +1 -0
- package/lib/module/ui/components/OxyProvider.js +3 -1
- package/lib/module/ui/components/OxyProvider.js.map +1 -1
- package/lib/module/ui/components/OxySignInButton.js +177 -7
- package/lib/module/ui/components/OxySignInButton.js.map +1 -1
- package/lib/module/ui/components/oauthNavigation.js +70 -0
- package/lib/module/ui/components/oauthNavigation.js.map +1 -0
- package/lib/module/ui/context/OxyContext.js +16 -2
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +4 -1
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyAccountDialog.d.ts +18 -2
- package/lib/typescript/commonjs/ui/components/OxyAccountDialog.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts +45 -0
- package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxySignInButton.d.ts +56 -0
- package/lib/typescript/commonjs/ui/components/OxySignInButton.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/oauthNavigation.d.ts +36 -0
- package/lib/typescript/commonjs/ui/components/oauthNavigation.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +14 -0
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/useServicesQueries.d.ts +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/useServicesQueries.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/types/navigation.d.ts +18 -0
- package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +4 -1
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts +18 -2
- package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts +45 -0
- package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxySignInButton.d.ts +56 -0
- package/lib/typescript/module/ui/components/OxySignInButton.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/oauthNavigation.d.ts +36 -0
- package/lib/typescript/module/ui/components/oauthNavigation.d.ts.map +1 -0
- package/lib/typescript/module/ui/context/OxyContext.d.ts +14 -0
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/queries/useServicesQueries.d.ts +1 -1
- package/lib/typescript/module/ui/hooks/queries/useServicesQueries.d.ts.map +1 -1
- package/lib/typescript/module/ui/types/navigation.d.ts +18 -0
- package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +12 -1
- package/src/ui/components/OxyAccountDialog.tsx +60 -85
- package/src/ui/components/OxyConsentScreen.tsx +456 -0
- package/src/ui/components/OxyProvider.tsx +2 -0
- package/src/ui/components/OxySignInButton.tsx +222 -6
- package/src/ui/components/__tests__/OxyConsentScreen.test.tsx +130 -0
- package/src/ui/components/oauthNavigation.ts +89 -0
- package/src/ui/context/OxyContext.tsx +30 -2
- package/src/ui/context/__tests__/OxyContext.coldBoot.test.tsx +165 -0
- package/src/ui/types/navigation.ts +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/services",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "18.0.0",
|
|
4
4
|
"description": "OxyHQ Expo/React Native SDK — UI components, screens, and native features",
|
|
5
5
|
"main": "lib/commonjs/index.js",
|
|
6
6
|
"module": "lib/module/index.js",
|
|
@@ -106,10 +106,10 @@
|
|
|
106
106
|
}
|
|
107
107
|
},
|
|
108
108
|
"dependencies": {
|
|
109
|
-
"@oxyhq/contracts": "0.
|
|
109
|
+
"@oxyhq/contracts": "0.11.0"
|
|
110
110
|
},
|
|
111
111
|
"devDependencies": {
|
|
112
|
-
"@oxyhq/core": "
|
|
112
|
+
"@oxyhq/core": "8.0.0",
|
|
113
113
|
"nativewind": "5.0.0-preview.3",
|
|
114
114
|
"react-native-css": "^3.0.0",
|
|
115
115
|
"@react-native-async-storage/async-storage": "^2.0.0",
|
|
@@ -153,7 +153,7 @@
|
|
|
153
153
|
"peerDependencies": {
|
|
154
154
|
"@expo/vector-icons": "^15.0.3",
|
|
155
155
|
"@oxyhq/bloom": ">=0.16.0",
|
|
156
|
-
"@oxyhq/core": "^
|
|
156
|
+
"@oxyhq/core": "^8.0.0",
|
|
157
157
|
"@react-native-async-storage/async-storage": "^2.0.0",
|
|
158
158
|
"@react-native-community/netinfo": "^11.4.1",
|
|
159
159
|
"@tanstack/query-async-storage-persister": "^5.101",
|
package/src/index.ts
CHANGED
|
@@ -192,10 +192,21 @@ export type { ViewMode, SortBy, SortOrder } from './ui/hooks/useFileFiltering';
|
|
|
192
192
|
// ---------------------------------------------------------------------------
|
|
193
193
|
export { default as Avatar } from './ui/components/Avatar';
|
|
194
194
|
export type { AvatarProps } from './ui/components/Avatar';
|
|
195
|
-
export {
|
|
195
|
+
export {
|
|
196
|
+
OxySignInButton,
|
|
197
|
+
OXY_OAUTH_STATE_STORAGE_KEY,
|
|
198
|
+
OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY,
|
|
199
|
+
} from './ui/components/OxySignInButton';
|
|
200
|
+
export type { OxySignInButtonProps, OxyOAuthResult } from './ui/components/OxySignInButton';
|
|
196
201
|
export { OxyAuthPrompt } from './ui/components/OxyAuthPrompt';
|
|
197
202
|
export type { OxyAuthPromptProps } from './ui/components/OxyAuthPrompt';
|
|
198
203
|
export { default as OxyLogo } from './ui/components/OxyLogo';
|
|
204
|
+
export { OxyConsentScreen } from './ui/components/OxyConsentScreen';
|
|
205
|
+
export type {
|
|
206
|
+
OxyConsentScreenProps,
|
|
207
|
+
OxyConsentApplication,
|
|
208
|
+
OxyConsentUser,
|
|
209
|
+
} from './ui/components/OxyConsentScreen';
|
|
199
210
|
|
|
200
211
|
// Optional signed-out gate primitive. Wrap any subtree (or the whole app via
|
|
201
212
|
// `OxyProvider`'s `requireAuth` prop) to opt into a shared, readiness-safe wall.
|
|
@@ -22,14 +22,29 @@
|
|
|
22
22
|
* Per-account color re-theming uses Bloom's `APP_COLOR_PRESETS` + `BloomColorScope`
|
|
23
23
|
* (same visual language auth.oxy.so uses). Base theming is `useTheme()` + a
|
|
24
24
|
* `StyleSheet`, so the dialog renders correctly in EVERY consumer — including apps
|
|
25
|
-
* that do not use NativeWind (e.g. the accounts app).
|
|
26
|
-
*
|
|
25
|
+
* that do not use NativeWind (e.g. the accounts app).
|
|
26
|
+
*
|
|
27
|
+
* The surface is Bloom's `<Dialog>` (`@oxyhq/bloom/dialog`) with a responsive
|
|
28
|
+
* `placement` — a bottom sheet on narrow viewports, a centered card on wide ones.
|
|
29
|
+
* It REPLACES the hand-rolled RN `<Modal>` + `<GestureHandlerRootView>` + manual
|
|
30
|
+
* backdrop/card wrapper this component used before. That RN `<Modal>` is invisible
|
|
31
|
+
* under React StrictMode on web: react-native-web's `ModalPortal` appends its host
|
|
32
|
+
* node during render but removes it in an effect cleanup and never re-attaches, so
|
|
33
|
+
* the dialog never paints in a dev Vite build. Bloom's `<Dialog>` renders through
|
|
34
|
+
* its own Portal and has no such lifecycle hazard.
|
|
35
|
+
*
|
|
36
|
+
* Open/close is CONTROLLED by `isAccountDialogOpen` (the `open` prop); the Dialog
|
|
37
|
+
* stays mounted whenever the controller exists so it can animate its own close.
|
|
38
|
+
* Backdrop / swipe-to-dismiss is disabled (`dismissOnBackdrop={false}`) on purpose:
|
|
39
|
+
* Bloom's controlled `bottom` placement does not fire `onClose` on a gesture or
|
|
40
|
+
* backdrop dismissal, so allowing it would desync `isAccountDialogOpen` from the
|
|
41
|
+
* sheet and block reopening. The header close button (and a successful switch)
|
|
42
|
+
* drive `closeAccountDialog`, which flips `open` and runs the exit animation.
|
|
27
43
|
*/
|
|
28
44
|
|
|
29
45
|
import type React from 'react';
|
|
30
46
|
import { useCallback, useMemo, useState, useSyncExternalStore } from 'react';
|
|
31
47
|
import {
|
|
32
|
-
Modal,
|
|
33
48
|
Platform,
|
|
34
49
|
Pressable,
|
|
35
50
|
ScrollView,
|
|
@@ -38,11 +53,11 @@ import {
|
|
|
38
53
|
type StyleProp,
|
|
39
54
|
type ViewStyle,
|
|
40
55
|
} from 'react-native';
|
|
41
|
-
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
|
42
56
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
43
57
|
import QRCode from 'react-native-qrcode-svg';
|
|
44
58
|
import { Avatar } from '@oxyhq/bloom/avatar';
|
|
45
59
|
import { Button } from '@oxyhq/bloom/button';
|
|
60
|
+
import { Dialog } from '@oxyhq/bloom/dialog';
|
|
46
61
|
import { Text } from '@oxyhq/bloom/typography';
|
|
47
62
|
import {
|
|
48
63
|
useTheme,
|
|
@@ -172,7 +187,7 @@ const OxyAccountDialog: React.FC = () => {
|
|
|
172
187
|
[handleSwitch, controller, handleManage, closeAccountDialog],
|
|
173
188
|
);
|
|
174
189
|
|
|
175
|
-
if (!
|
|
190
|
+
if (!controller) {
|
|
176
191
|
return null;
|
|
177
192
|
}
|
|
178
193
|
|
|
@@ -180,65 +195,48 @@ const OxyAccountDialog: React.FC = () => {
|
|
|
180
195
|
const showBack = view === 'qr' || (view === 'add' && snapshot.accounts.length > 0);
|
|
181
196
|
|
|
182
197
|
return (
|
|
183
|
-
<
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
198
|
+
<Dialog
|
|
199
|
+
open={isAccountDialogOpen}
|
|
200
|
+
onClose={closeAccountDialog}
|
|
201
|
+
placement={{ base: 'bottom', md: 'center' }}
|
|
202
|
+
dismissOnBackdrop={false}
|
|
203
|
+
maxWidth={420}
|
|
204
|
+
label={headerCopy(view, snapshot.accounts.length, t).title}
|
|
189
205
|
>
|
|
190
|
-
<
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
206
|
+
<DialogHeader
|
|
207
|
+
snapshot={snapshot}
|
|
208
|
+
theme={theme}
|
|
209
|
+
t={t}
|
|
210
|
+
showBack={showBack}
|
|
211
|
+
onBack={() => controller.setView('accounts')}
|
|
212
|
+
onClose={closeAccountDialog}
|
|
213
|
+
/>
|
|
214
|
+
<ScrollView
|
|
215
|
+
style={styles.body}
|
|
216
|
+
contentContainerStyle={styles.bodyContent}
|
|
217
|
+
showsVerticalScrollIndicator={false}
|
|
218
|
+
>
|
|
219
|
+
{view === 'accounts' ? (
|
|
220
|
+
<AccountsView snapshot={snapshot} theme={theme} t={t} handlers={handlers} />
|
|
221
|
+
) : view === 'qr' ? (
|
|
222
|
+
<QrView snapshot={snapshot} theme={theme} t={t} onRetry={() => void controller.showQr()} />
|
|
223
|
+
) : (
|
|
224
|
+
<SignInView
|
|
225
|
+
snapshot={snapshot}
|
|
226
|
+
theme={theme}
|
|
227
|
+
t={t}
|
|
228
|
+
handlers={handlers}
|
|
229
|
+
onSignInWithOxy={() => void controller.signInWithOxy()}
|
|
230
|
+
onScanQr={() => void controller.showQr()}
|
|
231
|
+
onUsePassword={() =>
|
|
232
|
+
controller.openPasswordAtOxyAuth({
|
|
233
|
+
returnUrl: isWeb ? currentHref() : undefined,
|
|
234
|
+
})
|
|
235
|
+
}
|
|
197
236
|
/>
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
isWeb ? styles.cardCentered : styles.cardSheet,
|
|
202
|
-
{ backgroundColor: theme.colors.card, borderColor: theme.colors.border },
|
|
203
|
-
]}
|
|
204
|
-
>
|
|
205
|
-
<DialogHeader
|
|
206
|
-
snapshot={snapshot}
|
|
207
|
-
theme={theme}
|
|
208
|
-
t={t}
|
|
209
|
-
showBack={showBack}
|
|
210
|
-
onBack={() => controller.setView('accounts')}
|
|
211
|
-
onClose={closeAccountDialog}
|
|
212
|
-
/>
|
|
213
|
-
<ScrollView
|
|
214
|
-
style={styles.body}
|
|
215
|
-
contentContainerStyle={styles.bodyContent}
|
|
216
|
-
showsVerticalScrollIndicator={false}
|
|
217
|
-
>
|
|
218
|
-
{view === 'accounts' ? (
|
|
219
|
-
<AccountsView snapshot={snapshot} theme={theme} t={t} handlers={handlers} />
|
|
220
|
-
) : view === 'qr' ? (
|
|
221
|
-
<QrView snapshot={snapshot} theme={theme} t={t} onRetry={() => void controller.showQr()} />
|
|
222
|
-
) : (
|
|
223
|
-
<SignInView
|
|
224
|
-
snapshot={snapshot}
|
|
225
|
-
theme={theme}
|
|
226
|
-
t={t}
|
|
227
|
-
handlers={handlers}
|
|
228
|
-
onSignInWithOxy={() => void controller.signInWithOxy()}
|
|
229
|
-
onScanQr={() => void controller.showQr()}
|
|
230
|
-
onUsePassword={() =>
|
|
231
|
-
controller.openPasswordAtOxyAuth({
|
|
232
|
-
returnUrl: isWeb ? currentHref() : undefined,
|
|
233
|
-
})
|
|
234
|
-
}
|
|
235
|
-
/>
|
|
236
|
-
)}
|
|
237
|
-
</ScrollView>
|
|
238
|
-
</View>
|
|
239
|
-
</View>
|
|
240
|
-
</GestureHandlerRootView>
|
|
241
|
-
</Modal>
|
|
237
|
+
)}
|
|
238
|
+
</ScrollView>
|
|
239
|
+
</Dialog>
|
|
242
240
|
);
|
|
243
241
|
};
|
|
244
242
|
|
|
@@ -583,29 +581,6 @@ const EMPTY_SNAPSHOT: AccountDialogSnapshot = {
|
|
|
583
581
|
};
|
|
584
582
|
|
|
585
583
|
const styles = StyleSheet.create({
|
|
586
|
-
root: {
|
|
587
|
-
flex: 1,
|
|
588
|
-
},
|
|
589
|
-
backdrop: {
|
|
590
|
-
flex: 1,
|
|
591
|
-
justifyContent: isWeb ? 'center' : 'flex-end',
|
|
592
|
-
alignItems: 'center',
|
|
593
|
-
},
|
|
594
|
-
card: {
|
|
595
|
-
width: '100%',
|
|
596
|
-
maxWidth: 420,
|
|
597
|
-
borderWidth: StyleSheet.hairlineWidth,
|
|
598
|
-
paddingHorizontal: 20,
|
|
599
|
-
paddingTop: 16,
|
|
600
|
-
paddingBottom: 24,
|
|
601
|
-
},
|
|
602
|
-
cardCentered: {
|
|
603
|
-
borderRadius: 28,
|
|
604
|
-
},
|
|
605
|
-
cardSheet: {
|
|
606
|
-
borderTopLeftRadius: 28,
|
|
607
|
-
borderTopRightRadius: 28,
|
|
608
|
-
},
|
|
609
584
|
header: {
|
|
610
585
|
alignItems: 'center',
|
|
611
586
|
marginBottom: 12,
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OxyConsentScreen — the unified OAuth authorize/consent surface for
|
|
3
|
+
* `@oxyhq/services`.
|
|
4
|
+
*
|
|
5
|
+
* A PURE, presentational React Native component: it renders the resolved
|
|
6
|
+
* requesting-application identity, the permissions (scopes) being requested,
|
|
7
|
+
* the account that will authorize the request, and the allow/deny affordances —
|
|
8
|
+
* and delegates every decision back through {@link OxyConsentScreenProps.onAllow}
|
|
9
|
+
* / {@link OxyConsentScreenProps.onDeny}. It performs NO data fetching and owns
|
|
10
|
+
* NO session state: the caller resolves the application + scopes + user (the IdP
|
|
11
|
+
* from `GET /auth/session/approve-info/:code`, Console from its own request) and
|
|
12
|
+
* drives the `busy` / `error` flags. This is the RN/Bloom port of the web
|
|
13
|
+
* `consent-card` (`packages/auth`), so the two consent surfaces stay in visual
|
|
14
|
+
* and behavioral lockstep.
|
|
15
|
+
*
|
|
16
|
+
* Theming is `useTheme()` + a `StyleSheet` (the same approach as
|
|
17
|
+
* {@link OxyAccountDialog}) so it renders correctly in EVERY consumer, including
|
|
18
|
+
* apps that do not use NativeWind. Copy is localized through {@link useI18n}
|
|
19
|
+
* under the `consent.*` keys.
|
|
20
|
+
*/
|
|
21
|
+
import { useCallback } from 'react';
|
|
22
|
+
import { Linking, Pressable, ScrollView, StyleSheet, View } from 'react-native';
|
|
23
|
+
import { logger } from '@oxyhq/core';
|
|
24
|
+
import { Avatar } from '@oxyhq/bloom/avatar';
|
|
25
|
+
import { Button } from '@oxyhq/bloom/button';
|
|
26
|
+
import { Text } from '@oxyhq/bloom/typography';
|
|
27
|
+
import { useTheme } from '@oxyhq/bloom/theme';
|
|
28
|
+
import { useI18n } from '../hooks/useI18n';
|
|
29
|
+
import OxyLogo from './OxyLogo';
|
|
30
|
+
|
|
31
|
+
/** The requesting application, resolved by the caller (never user-supplied raw). */
|
|
32
|
+
export interface OxyConsentApplication {
|
|
33
|
+
/** Display name of the requesting application. */
|
|
34
|
+
name: string;
|
|
35
|
+
/** Application icon — a URL or bare file id passed straight to `<Avatar source>`. */
|
|
36
|
+
iconUrl?: string;
|
|
37
|
+
/** Public website of the application, shown as a link when present. */
|
|
38
|
+
websiteUrl?: string;
|
|
39
|
+
/** Privacy policy URL, shown as a link when present. */
|
|
40
|
+
privacyPolicyUrl?: string;
|
|
41
|
+
/** Terms of service URL, shown as a link when present. */
|
|
42
|
+
termsUrl?: string;
|
|
43
|
+
/** Human-readable publisher, used for the "Published by …" provenance line. */
|
|
44
|
+
developerName?: string;
|
|
45
|
+
/** True for first-party / official Oxy applications. */
|
|
46
|
+
isOfficial?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The account that will authorize the request (the currently signed-in user). */
|
|
50
|
+
export interface OxyConsentUser {
|
|
51
|
+
/** Real display name, when the profile has one. */
|
|
52
|
+
displayName?: string;
|
|
53
|
+
/** Normalized handle — the sanctioned fallback when `displayName` is absent. */
|
|
54
|
+
handle?: string;
|
|
55
|
+
/** Avatar — a URL or bare file id passed straight to `<Avatar source>`. */
|
|
56
|
+
avatarUri?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface OxyConsentScreenProps {
|
|
60
|
+
/** The resolved requesting application. */
|
|
61
|
+
application: OxyConsentApplication;
|
|
62
|
+
/** OAuth scopes requested by the client (mapped to friendly labels). */
|
|
63
|
+
scopes: string[];
|
|
64
|
+
/** The account that will authorize the request, when known. */
|
|
65
|
+
user?: OxyConsentUser;
|
|
66
|
+
/** Approve handler — the caller mints the code / completes the flow. */
|
|
67
|
+
onAllow: () => void | Promise<void>;
|
|
68
|
+
/** Deny handler — the caller cancels the flow. */
|
|
69
|
+
onDeny: () => void;
|
|
70
|
+
/** True while a decision is in flight; disables both actions and spins Allow. */
|
|
71
|
+
busy?: boolean;
|
|
72
|
+
/** A blocking error message for the request, when present. */
|
|
73
|
+
error?: string | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Friendly-label i18n keys for the scopes the platform issues. Standard OIDC
|
|
78
|
+
* scopes (`openid` / `profile` / `email` / `offline_access`) and the Oxy scope
|
|
79
|
+
* set both map to a curated `consent.scopes.*` sentence. Unknown scopes fall
|
|
80
|
+
* back to the raw scope string so the user always sees something concrete.
|
|
81
|
+
*/
|
|
82
|
+
const SCOPE_LABEL_KEYS: Record<string, string> = {
|
|
83
|
+
openid: 'consent.scopes.openid',
|
|
84
|
+
profile: 'consent.scopes.profile',
|
|
85
|
+
email: 'consent.scopes.email',
|
|
86
|
+
offline_access: 'consent.scopes.offlineAccess',
|
|
87
|
+
'user:read': 'consent.scopes.userRead',
|
|
88
|
+
'files:read': 'consent.scopes.filesRead',
|
|
89
|
+
'files:write': 'consent.scopes.filesWrite',
|
|
90
|
+
'files:delete': 'consent.scopes.filesDelete',
|
|
91
|
+
'webhooks:receive': 'consent.scopes.webhooksReceive',
|
|
92
|
+
'chat:completions': 'consent.scopes.chatCompletions',
|
|
93
|
+
'models:read': 'consent.scopes.modelsRead',
|
|
94
|
+
'federation:write': 'consent.scopes.federationWrite',
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
type Translate = ReturnType<typeof useI18n>['t'];
|
|
98
|
+
|
|
99
|
+
function scopeLabel(scope: string, t: Translate): string {
|
|
100
|
+
const key = SCOPE_LABEL_KEYS[scope];
|
|
101
|
+
return key ? t(key) : scope;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A tappable legal/website link that opens the URL in the platform browser. */
|
|
105
|
+
function ConsentLink({
|
|
106
|
+
label,
|
|
107
|
+
url,
|
|
108
|
+
testID,
|
|
109
|
+
color,
|
|
110
|
+
}: {
|
|
111
|
+
label: string;
|
|
112
|
+
url: string;
|
|
113
|
+
testID: string;
|
|
114
|
+
color: string;
|
|
115
|
+
}) {
|
|
116
|
+
const handlePress = useCallback(() => {
|
|
117
|
+
// `websiteUrl`/legal URLs are application-controlled metadata rendered on a
|
|
118
|
+
// high-trust surface: only ever hand web schemes to the OS.
|
|
119
|
+
let protocol: string;
|
|
120
|
+
try {
|
|
121
|
+
protocol = new URL(url).protocol;
|
|
122
|
+
} catch {
|
|
123
|
+
logger.warn('OxyConsentScreen: invalid link URL', { url });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (protocol !== 'https:' && protocol !== 'http:') {
|
|
127
|
+
logger.warn('OxyConsentScreen: blocked non-web link scheme', { url });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
Linking.openURL(url).catch((error) => {
|
|
131
|
+
logger.warn('OxyConsentScreen: could not open link', { url, error });
|
|
132
|
+
});
|
|
133
|
+
}, [url]);
|
|
134
|
+
return (
|
|
135
|
+
<Pressable testID={testID} onPress={handlePress} accessibilityRole="link" style={styles.link}>
|
|
136
|
+
<Text style={[styles.linkText, { color }]}>{label}</Text>
|
|
137
|
+
</Pressable>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function OxyConsentScreen({
|
|
142
|
+
application,
|
|
143
|
+
scopes,
|
|
144
|
+
user,
|
|
145
|
+
onAllow,
|
|
146
|
+
onDeny,
|
|
147
|
+
busy = false,
|
|
148
|
+
error = null,
|
|
149
|
+
}: OxyConsentScreenProps) {
|
|
150
|
+
const theme = useTheme();
|
|
151
|
+
const { t } = useI18n();
|
|
152
|
+
const appName = application.name;
|
|
153
|
+
// Scopes key the permission rows — a duplicate in the request would mean
|
|
154
|
+
// duplicate React keys and redundant rows.
|
|
155
|
+
const uniqueScopes = [...new Set(scopes)];
|
|
156
|
+
|
|
157
|
+
const provenanceLabel = application.isOfficial
|
|
158
|
+
? t('consent.provenance.official')
|
|
159
|
+
: application.developerName
|
|
160
|
+
? t('consent.provenance.developer', { developer: application.developerName })
|
|
161
|
+
: t('consent.provenance.thirdParty');
|
|
162
|
+
|
|
163
|
+
// Display-name rule (D5): a real display name, else the normalized handle.
|
|
164
|
+
const accountName = user ? user.displayName?.trim() || user.handle : undefined;
|
|
165
|
+
|
|
166
|
+
const handleAllow = useCallback(() => {
|
|
167
|
+
void onAllow();
|
|
168
|
+
}, [onAllow]);
|
|
169
|
+
|
|
170
|
+
const showLegalLinks = Boolean(application.privacyPolicyUrl || application.termsUrl);
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<ScrollView
|
|
174
|
+
testID="oxy-consent-screen"
|
|
175
|
+
style={styles.root}
|
|
176
|
+
contentContainerStyle={styles.content}
|
|
177
|
+
>
|
|
178
|
+
{/* Header: requesting app ↔ Oxy identity */}
|
|
179
|
+
<View style={styles.header}>
|
|
180
|
+
<View style={styles.connection}>
|
|
181
|
+
<Avatar source={application.iconUrl} name={appName} size={56} />
|
|
182
|
+
<View style={styles.connector}>
|
|
183
|
+
<View style={[styles.dot, { backgroundColor: theme.colors.border }]} />
|
|
184
|
+
<View style={[styles.dot, { backgroundColor: theme.colors.border }]} />
|
|
185
|
+
<View style={[styles.dot, { backgroundColor: theme.colors.border }]} />
|
|
186
|
+
</View>
|
|
187
|
+
<View style={[styles.logoBadge, { backgroundColor: theme.colors.backgroundSecondary ?? theme.colors.card }]}>
|
|
188
|
+
<OxyLogo variant="icon" size={30} fillColor={theme.colors.primary} />
|
|
189
|
+
</View>
|
|
190
|
+
</View>
|
|
191
|
+
<Text style={[styles.title, { color: theme.colors.text }]}>
|
|
192
|
+
{t('consent.title', { app: appName })}
|
|
193
|
+
</Text>
|
|
194
|
+
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
|
|
195
|
+
{t('consent.subtitle', { app: appName })}
|
|
196
|
+
</Text>
|
|
197
|
+
</View>
|
|
198
|
+
|
|
199
|
+
{/* Provenance */}
|
|
200
|
+
<View style={[styles.card, { borderColor: theme.colors.border, backgroundColor: theme.colors.card }]}>
|
|
201
|
+
<Text testID="consent-provenance" style={[styles.provenance, { color: theme.colors.text }]}>
|
|
202
|
+
{provenanceLabel}
|
|
203
|
+
</Text>
|
|
204
|
+
{application.websiteUrl ? (
|
|
205
|
+
<ConsentLink
|
|
206
|
+
testID="consent-link-website"
|
|
207
|
+
label={application.websiteUrl}
|
|
208
|
+
url={application.websiteUrl}
|
|
209
|
+
color={theme.colors.primary}
|
|
210
|
+
/>
|
|
211
|
+
) : null}
|
|
212
|
+
</View>
|
|
213
|
+
|
|
214
|
+
{/* Requested permissions */}
|
|
215
|
+
<View style={styles.section}>
|
|
216
|
+
<Text style={[styles.sectionLabel, { color: theme.colors.textSecondary }]}>
|
|
217
|
+
{t('consent.permissions.title')}
|
|
218
|
+
</Text>
|
|
219
|
+
<View style={[styles.card, { borderColor: theme.colors.border, backgroundColor: theme.colors.card }]}>
|
|
220
|
+
{uniqueScopes.length > 0 ? (
|
|
221
|
+
uniqueScopes.map((scope) => (
|
|
222
|
+
<View key={scope} testID={`consent-scope-${scope}`} style={styles.row}>
|
|
223
|
+
<View style={[styles.bullet, { backgroundColor: theme.colors.primary }]} />
|
|
224
|
+
<Text style={[styles.rowText, { color: theme.colors.text }]}>
|
|
225
|
+
{scopeLabel(scope, t)}
|
|
226
|
+
</Text>
|
|
227
|
+
</View>
|
|
228
|
+
))
|
|
229
|
+
) : (
|
|
230
|
+
<View testID="consent-scope-basic" style={styles.row}>
|
|
231
|
+
<View style={[styles.bullet, { backgroundColor: theme.colors.primary }]} />
|
|
232
|
+
<Text style={[styles.rowText, { color: theme.colors.text }]}>
|
|
233
|
+
{t('consent.permissions.basic')}
|
|
234
|
+
</Text>
|
|
235
|
+
</View>
|
|
236
|
+
)}
|
|
237
|
+
</View>
|
|
238
|
+
</View>
|
|
239
|
+
|
|
240
|
+
{/* Authorizing account */}
|
|
241
|
+
{user ? (
|
|
242
|
+
<View
|
|
243
|
+
testID="consent-account"
|
|
244
|
+
style={[styles.accountRow, { borderColor: theme.colors.border, backgroundColor: theme.colors.card }]}
|
|
245
|
+
>
|
|
246
|
+
<Avatar source={user.avatarUri} name={accountName} size={40} />
|
|
247
|
+
<View style={styles.accountText}>
|
|
248
|
+
<Text
|
|
249
|
+
testID="consent-account-name"
|
|
250
|
+
numberOfLines={1}
|
|
251
|
+
style={[styles.accountName, { color: theme.colors.text }]}
|
|
252
|
+
>
|
|
253
|
+
{accountName}
|
|
254
|
+
</Text>
|
|
255
|
+
{user.handle && user.handle !== accountName ? (
|
|
256
|
+
<Text numberOfLines={1} style={[styles.accountHandle, { color: theme.colors.textSecondary }]}>
|
|
257
|
+
{user.handle}
|
|
258
|
+
</Text>
|
|
259
|
+
) : null}
|
|
260
|
+
</View>
|
|
261
|
+
</View>
|
|
262
|
+
) : null}
|
|
263
|
+
|
|
264
|
+
{/* Legal links */}
|
|
265
|
+
{showLegalLinks ? (
|
|
266
|
+
<View style={styles.linksRow}>
|
|
267
|
+
{application.privacyPolicyUrl ? (
|
|
268
|
+
<ConsentLink
|
|
269
|
+
testID="consent-link-privacy"
|
|
270
|
+
label={t('consent.links.privacy')}
|
|
271
|
+
url={application.privacyPolicyUrl}
|
|
272
|
+
color={theme.colors.textSecondary}
|
|
273
|
+
/>
|
|
274
|
+
) : null}
|
|
275
|
+
{application.termsUrl ? (
|
|
276
|
+
<ConsentLink
|
|
277
|
+
testID="consent-link-terms"
|
|
278
|
+
label={t('consent.links.terms')}
|
|
279
|
+
url={application.termsUrl}
|
|
280
|
+
color={theme.colors.textSecondary}
|
|
281
|
+
/>
|
|
282
|
+
) : null}
|
|
283
|
+
</View>
|
|
284
|
+
) : null}
|
|
285
|
+
|
|
286
|
+
{/* Blocking error */}
|
|
287
|
+
{error ? (
|
|
288
|
+
<View
|
|
289
|
+
testID="consent-error"
|
|
290
|
+
style={[styles.errorCard, { borderColor: theme.colors.error }]}
|
|
291
|
+
>
|
|
292
|
+
<Text style={[styles.errorText, { color: theme.colors.error }]}>{error}</Text>
|
|
293
|
+
</View>
|
|
294
|
+
) : null}
|
|
295
|
+
|
|
296
|
+
{/* Decision actions */}
|
|
297
|
+
<View style={styles.actions}>
|
|
298
|
+
<Button
|
|
299
|
+
testID="consent-allow"
|
|
300
|
+
variant="primary"
|
|
301
|
+
onPress={handleAllow}
|
|
302
|
+
disabled={busy}
|
|
303
|
+
loading={busy}
|
|
304
|
+
style={styles.actionButton}
|
|
305
|
+
>
|
|
306
|
+
{t('consent.allow', { app: appName })}
|
|
307
|
+
</Button>
|
|
308
|
+
<Button
|
|
309
|
+
testID="consent-deny"
|
|
310
|
+
variant="ghost"
|
|
311
|
+
onPress={onDeny}
|
|
312
|
+
disabled={busy}
|
|
313
|
+
style={styles.actionButton}
|
|
314
|
+
>
|
|
315
|
+
{t('consent.deny')}
|
|
316
|
+
</Button>
|
|
317
|
+
</View>
|
|
318
|
+
|
|
319
|
+
<Text style={[styles.disclaimer, { color: theme.colors.textSecondary }]}>
|
|
320
|
+
{t('consent.disclaimer', { app: appName })}
|
|
321
|
+
</Text>
|
|
322
|
+
</ScrollView>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const styles = StyleSheet.create({
|
|
327
|
+
root: {
|
|
328
|
+
flex: 1,
|
|
329
|
+
},
|
|
330
|
+
content: {
|
|
331
|
+
padding: 20,
|
|
332
|
+
gap: 20,
|
|
333
|
+
},
|
|
334
|
+
header: {
|
|
335
|
+
alignItems: 'center',
|
|
336
|
+
gap: 12,
|
|
337
|
+
},
|
|
338
|
+
connection: {
|
|
339
|
+
flexDirection: 'row',
|
|
340
|
+
alignItems: 'center',
|
|
341
|
+
gap: 10,
|
|
342
|
+
},
|
|
343
|
+
connector: {
|
|
344
|
+
flexDirection: 'row',
|
|
345
|
+
alignItems: 'center',
|
|
346
|
+
gap: 4,
|
|
347
|
+
},
|
|
348
|
+
dot: {
|
|
349
|
+
width: 4,
|
|
350
|
+
height: 4,
|
|
351
|
+
borderRadius: 2,
|
|
352
|
+
},
|
|
353
|
+
logoBadge: {
|
|
354
|
+
width: 56,
|
|
355
|
+
height: 56,
|
|
356
|
+
borderRadius: 28,
|
|
357
|
+
alignItems: 'center',
|
|
358
|
+
justifyContent: 'center',
|
|
359
|
+
},
|
|
360
|
+
title: {
|
|
361
|
+
fontSize: 20,
|
|
362
|
+
fontWeight: '700',
|
|
363
|
+
textAlign: 'center',
|
|
364
|
+
},
|
|
365
|
+
subtitle: {
|
|
366
|
+
fontSize: 14,
|
|
367
|
+
lineHeight: 20,
|
|
368
|
+
textAlign: 'center',
|
|
369
|
+
},
|
|
370
|
+
section: {
|
|
371
|
+
gap: 8,
|
|
372
|
+
},
|
|
373
|
+
sectionLabel: {
|
|
374
|
+
fontSize: 12,
|
|
375
|
+
fontWeight: '600',
|
|
376
|
+
paddingHorizontal: 4,
|
|
377
|
+
},
|
|
378
|
+
card: {
|
|
379
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
380
|
+
borderRadius: 16,
|
|
381
|
+
padding: 12,
|
|
382
|
+
gap: 10,
|
|
383
|
+
},
|
|
384
|
+
provenance: {
|
|
385
|
+
fontSize: 14,
|
|
386
|
+
fontWeight: '600',
|
|
387
|
+
},
|
|
388
|
+
row: {
|
|
389
|
+
flexDirection: 'row',
|
|
390
|
+
alignItems: 'center',
|
|
391
|
+
gap: 10,
|
|
392
|
+
},
|
|
393
|
+
bullet: {
|
|
394
|
+
width: 6,
|
|
395
|
+
height: 6,
|
|
396
|
+
borderRadius: 3,
|
|
397
|
+
},
|
|
398
|
+
rowText: {
|
|
399
|
+
flex: 1,
|
|
400
|
+
fontSize: 14,
|
|
401
|
+
},
|
|
402
|
+
accountRow: {
|
|
403
|
+
flexDirection: 'row',
|
|
404
|
+
alignItems: 'center',
|
|
405
|
+
gap: 12,
|
|
406
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
407
|
+
borderRadius: 16,
|
|
408
|
+
padding: 12,
|
|
409
|
+
},
|
|
410
|
+
accountText: {
|
|
411
|
+
flex: 1,
|
|
412
|
+
minWidth: 0,
|
|
413
|
+
},
|
|
414
|
+
accountName: {
|
|
415
|
+
fontSize: 15,
|
|
416
|
+
fontWeight: '600',
|
|
417
|
+
},
|
|
418
|
+
accountHandle: {
|
|
419
|
+
fontSize: 13,
|
|
420
|
+
},
|
|
421
|
+
linksRow: {
|
|
422
|
+
flexDirection: 'row',
|
|
423
|
+
flexWrap: 'wrap',
|
|
424
|
+
gap: 16,
|
|
425
|
+
paddingHorizontal: 4,
|
|
426
|
+
},
|
|
427
|
+
link: {
|
|
428
|
+
alignSelf: 'flex-start',
|
|
429
|
+
},
|
|
430
|
+
linkText: {
|
|
431
|
+
fontSize: 13,
|
|
432
|
+
textDecorationLine: 'underline',
|
|
433
|
+
},
|
|
434
|
+
errorCard: {
|
|
435
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
436
|
+
borderRadius: 12,
|
|
437
|
+
padding: 12,
|
|
438
|
+
},
|
|
439
|
+
errorText: {
|
|
440
|
+
fontSize: 14,
|
|
441
|
+
},
|
|
442
|
+
actions: {
|
|
443
|
+
gap: 12,
|
|
444
|
+
},
|
|
445
|
+
actionButton: {
|
|
446
|
+
width: '100%',
|
|
447
|
+
},
|
|
448
|
+
disclaimer: {
|
|
449
|
+
fontSize: 12,
|
|
450
|
+
lineHeight: 18,
|
|
451
|
+
textAlign: 'center',
|
|
452
|
+
paddingHorizontal: 4,
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
export default OxyConsentScreen;
|