@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.
Files changed (74) hide show
  1. package/README.md +77 -98
  2. package/lib/commonjs/index.js +19 -0
  3. package/lib/commonjs/index.js.map +1 -1
  4. package/lib/commonjs/ui/components/OxyAccountDialog.js +62 -84
  5. package/lib/commonjs/ui/components/OxyAccountDialog.js.map +1 -1
  6. package/lib/commonjs/ui/components/OxyConsentScreen.js +461 -0
  7. package/lib/commonjs/ui/components/OxyConsentScreen.js.map +1 -0
  8. package/lib/commonjs/ui/components/OxyProvider.js +3 -1
  9. package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
  10. package/lib/commonjs/ui/components/OxySignInButton.js +176 -7
  11. package/lib/commonjs/ui/components/OxySignInButton.js.map +1 -1
  12. package/lib/commonjs/ui/components/oauthNavigation.js +73 -0
  13. package/lib/commonjs/ui/components/oauthNavigation.js.map +1 -0
  14. package/lib/commonjs/ui/context/OxyContext.js +16 -2
  15. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  16. package/lib/module/index.js +2 -2
  17. package/lib/module/index.js.map +1 -1
  18. package/lib/module/ui/components/OxyAccountDialog.js +63 -85
  19. package/lib/module/ui/components/OxyAccountDialog.js.map +1 -1
  20. package/lib/module/ui/components/OxyConsentScreen.js +454 -0
  21. package/lib/module/ui/components/OxyConsentScreen.js.map +1 -0
  22. package/lib/module/ui/components/OxyProvider.js +3 -1
  23. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  24. package/lib/module/ui/components/OxySignInButton.js +177 -7
  25. package/lib/module/ui/components/OxySignInButton.js.map +1 -1
  26. package/lib/module/ui/components/oauthNavigation.js +70 -0
  27. package/lib/module/ui/components/oauthNavigation.js.map +1 -0
  28. package/lib/module/ui/context/OxyContext.js +16 -2
  29. package/lib/module/ui/context/OxyContext.js.map +1 -1
  30. package/lib/typescript/commonjs/index.d.ts +4 -1
  31. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/ui/components/OxyAccountDialog.d.ts +18 -2
  33. package/lib/typescript/commonjs/ui/components/OxyAccountDialog.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts +45 -0
  35. package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts.map +1 -0
  36. package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/ui/components/OxySignInButton.d.ts +56 -0
  38. package/lib/typescript/commonjs/ui/components/OxySignInButton.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/ui/components/oauthNavigation.d.ts +36 -0
  40. package/lib/typescript/commonjs/ui/components/oauthNavigation.d.ts.map +1 -0
  41. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +14 -0
  42. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/ui/hooks/queries/useServicesQueries.d.ts +1 -1
  44. package/lib/typescript/commonjs/ui/hooks/queries/useServicesQueries.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/ui/types/navigation.d.ts +18 -0
  46. package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
  47. package/lib/typescript/module/index.d.ts +4 -1
  48. package/lib/typescript/module/index.d.ts.map +1 -1
  49. package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts +18 -2
  50. package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts.map +1 -1
  51. package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts +45 -0
  52. package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts.map +1 -0
  53. package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
  54. package/lib/typescript/module/ui/components/OxySignInButton.d.ts +56 -0
  55. package/lib/typescript/module/ui/components/OxySignInButton.d.ts.map +1 -1
  56. package/lib/typescript/module/ui/components/oauthNavigation.d.ts +36 -0
  57. package/lib/typescript/module/ui/components/oauthNavigation.d.ts.map +1 -0
  58. package/lib/typescript/module/ui/context/OxyContext.d.ts +14 -0
  59. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  60. package/lib/typescript/module/ui/hooks/queries/useServicesQueries.d.ts +1 -1
  61. package/lib/typescript/module/ui/hooks/queries/useServicesQueries.d.ts.map +1 -1
  62. package/lib/typescript/module/ui/types/navigation.d.ts +18 -0
  63. package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
  64. package/package.json +4 -4
  65. package/src/index.ts +12 -1
  66. package/src/ui/components/OxyAccountDialog.tsx +60 -85
  67. package/src/ui/components/OxyConsentScreen.tsx +456 -0
  68. package/src/ui/components/OxyProvider.tsx +2 -0
  69. package/src/ui/components/OxySignInButton.tsx +222 -6
  70. package/src/ui/components/__tests__/OxyConsentScreen.test.tsx +130 -0
  71. package/src/ui/components/oauthNavigation.ts +89 -0
  72. package/src/ui/context/OxyContext.tsx +30 -2
  73. package/src/ui/context/__tests__/OxyContext.coldBoot.test.tsx +165 -0
  74. 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": "17.0.0",
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.10.0"
109
+ "@oxyhq/contracts": "0.11.0"
110
110
  },
111
111
  "devDependencies": {
112
- "@oxyhq/core": "^7.0.0",
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": "^7.1.0",
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 { OxySignInButton } from './ui/components/OxySignInButton';
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). Modal contents are wrapped
26
- * in `<GestureHandlerRootView>` because RN's `Modal` renders into its own window.
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 (!isAccountDialogOpen || !controller) {
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
- <Modal
184
- visible
185
- transparent
186
- animationType={isWeb ? 'fade' : 'slide'}
187
- statusBarTranslucent
188
- onRequestClose={closeAccountDialog}
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
- <GestureHandlerRootView style={styles.root}>
191
- <View style={[styles.backdrop, { backgroundColor: theme.colors.overlay }]}>
192
- <Pressable
193
- style={StyleSheet.absoluteFill}
194
- onPress={closeAccountDialog}
195
- accessibilityLabel={t('common.actions.close') || 'Close'}
196
- accessibilityRole="button"
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
- <View
199
- style={[
200
- styles.card,
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;