@phosra/connect 0.1.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 +313 -0
- package/package.json +56 -0
- package/src/core/controller.ts +186 -0
- package/src/core/index.ts +19 -0
- package/src/core/types.ts +122 -0
- package/src/core/useConnect.ts +68 -0
- package/src/index.ts +4 -0
- package/src/native/ConnectFlow.native.tsx +205 -0
- package/src/native/PhosraConnect.native.tsx +719 -0
- package/src/native/assets.native.tsx +98 -0
- package/src/native/index.ts +24 -0
- package/src/native/openAuthorizeUrl.native.ts +56 -0
- package/src/web/ConnectFlow.tsx +156 -0
- package/src/web/PhosraConnect.tsx +286 -0
- package/src/web/assets.tsx +84 -0
- package/src/web/connect.css +358 -0
- package/src/web/index.ts +10 -0
- package/src/web/openAuthorizeUrl.ts +81 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Wire contract for the Phosra Connect component. These types mirror the
|
|
2
|
+
// client-facing shapes of @phosra/link's server-side ceremony, but are defined
|
|
3
|
+
// LOCALLY so this package has zero server dependencies (no @phosra/link runtime,
|
|
4
|
+
// no pg). The PCA app implements a BFF that fulfils `ConnectTransport` by calling
|
|
5
|
+
// @phosra/link server-side (initPlatformOAuth / completePlatformOAuth / bindProfile
|
|
6
|
+
// / runConnectCeremony).
|
|
7
|
+
|
|
8
|
+
export type AgeHint = 'under_13' | '13_15' | '16_17';
|
|
9
|
+
|
|
10
|
+
/** A child account discovered on the platform after the parent authorizes. */
|
|
11
|
+
export interface ChildProfile {
|
|
12
|
+
id: string;
|
|
13
|
+
displayName: string;
|
|
14
|
+
ageHint?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** One safety rule shown in the pre-connect preview ("what Snaptr will apply & confirm"). */
|
|
18
|
+
export interface ConnectRule {
|
|
19
|
+
category: string;
|
|
20
|
+
label: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The enforcement platform being connected (e.g. Snaptr). */
|
|
24
|
+
export interface ConnectPlatform {
|
|
25
|
+
did: string;
|
|
26
|
+
name: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface InitResult {
|
|
30
|
+
authorizeUrl: string;
|
|
31
|
+
state: string;
|
|
32
|
+
sessionId: string;
|
|
33
|
+
}
|
|
34
|
+
export interface CompleteResult {
|
|
35
|
+
sessionId: string;
|
|
36
|
+
childProfiles: ChildProfile[];
|
|
37
|
+
}
|
|
38
|
+
export interface BindResult {
|
|
39
|
+
grant_id: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The three BFF calls the component drives. The PCA implements these against its
|
|
44
|
+
* own routes, which wrap @phosra/link server-side. The component NEVER talks to
|
|
45
|
+
* Phosra directly, and never sees the endpoint_id_label (the server delivers it
|
|
46
|
+
* to the platform).
|
|
47
|
+
*/
|
|
48
|
+
export interface ConnectTransport {
|
|
49
|
+
init(req: {
|
|
50
|
+
platformDid: string;
|
|
51
|
+
childId?: string;
|
|
52
|
+
grantedScope: string[];
|
|
53
|
+
ageHint?: AgeHint;
|
|
54
|
+
}): Promise<InitResult>;
|
|
55
|
+
complete(req: { code: string; state: string; sessionId: string }): Promise<CompleteResult>;
|
|
56
|
+
bind(req: {
|
|
57
|
+
sessionId: string;
|
|
58
|
+
platformChildProfileId: string;
|
|
59
|
+
childId: string;
|
|
60
|
+
grantedScope: string[];
|
|
61
|
+
ageHint?: AgeHint;
|
|
62
|
+
}): Promise<BindResult>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Injected per-platform. Opens the secure webview to `authorizeUrl` and resolves
|
|
67
|
+
* the redirect's `{code, state}` (or `{canceled:true}` if the parent backs out).
|
|
68
|
+
* Web supplies a popup/postMessage opener; React Native supplies an
|
|
69
|
+
* expo-web-browser opener. The core is platform-agnostic because this is injected.
|
|
70
|
+
*/
|
|
71
|
+
export type AuthorizeOpener = (
|
|
72
|
+
authorizeUrl: string,
|
|
73
|
+
redirectUri: string,
|
|
74
|
+
) => Promise<{ code: string; state: string } | { canceled: true }>;
|
|
75
|
+
|
|
76
|
+
export type ConnectStatus =
|
|
77
|
+
| 'idle'
|
|
78
|
+
| 'authorizing'
|
|
79
|
+
| 'exchanging'
|
|
80
|
+
| 'selecting'
|
|
81
|
+
| 'binding'
|
|
82
|
+
| 'success'
|
|
83
|
+
| 'error'
|
|
84
|
+
| 'canceled';
|
|
85
|
+
|
|
86
|
+
export interface ConnectError {
|
|
87
|
+
stage: ConnectStatus;
|
|
88
|
+
message: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ConnectState {
|
|
92
|
+
status: ConnectStatus;
|
|
93
|
+
childProfiles?: ChildProfile[];
|
|
94
|
+
result?: BindResult;
|
|
95
|
+
error?: ConnectError;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ConnectControllerOptions {
|
|
99
|
+
transport: ConnectTransport;
|
|
100
|
+
openAuthorizeUrl: AuthorizeOpener;
|
|
101
|
+
redirectUri: string;
|
|
102
|
+
platform: ConnectPlatform;
|
|
103
|
+
grantedScope: string[];
|
|
104
|
+
childId?: string;
|
|
105
|
+
ageHint?: AgeHint;
|
|
106
|
+
onSuccess?(r: BindResult): void;
|
|
107
|
+
onError?(e: ConnectError): void;
|
|
108
|
+
onExit?(): void;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface ConnectController {
|
|
112
|
+
getState(): ConnectState;
|
|
113
|
+
subscribe(fn: (s: ConnectState) => void): () => void;
|
|
114
|
+
/** Begin the ceremony (from idle / error / canceled). */
|
|
115
|
+
open(): Promise<void>;
|
|
116
|
+
/** Confirm the child to bind (valid only while status === 'selecting'). */
|
|
117
|
+
selectChild(profileId: string): Promise<void>;
|
|
118
|
+
/** Re-run from the failed stage. */
|
|
119
|
+
retry(): Promise<void>;
|
|
120
|
+
/** Abort → canceled, fires onExit. */
|
|
121
|
+
cancel(): void;
|
|
122
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useRef, useSyncExternalStore } from 'react';
|
|
2
|
+
import { createConnectController } from './controller.js';
|
|
3
|
+
import type { ConnectController, ConnectControllerOptions, ConnectState } from './types.js';
|
|
4
|
+
|
|
5
|
+
export interface UseConnectResult {
|
|
6
|
+
state: ConnectState;
|
|
7
|
+
open: ConnectController['open'];
|
|
8
|
+
selectChild: ConnectController['selectChild'];
|
|
9
|
+
retry: ConnectController['retry'];
|
|
10
|
+
cancel: ConnectController['cancel'];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Universal React hook wrapping the framework-agnostic ConnectController. Works
|
|
15
|
+
* on web and React Native (it imports only `react`, never `react-dom` or
|
|
16
|
+
* `react-native`). The controller is created once; option changes (fresh
|
|
17
|
+
* callbacks, transport, scope) are picked up lazily via a ref, so the controller
|
|
18
|
+
* identity — and therefore any in-flight ceremony — is stable across renders.
|
|
19
|
+
*/
|
|
20
|
+
export function useConnect(opts: ConnectControllerOptions): UseConnectResult {
|
|
21
|
+
const optsRef = useRef(opts);
|
|
22
|
+
optsRef.current = opts;
|
|
23
|
+
|
|
24
|
+
const controllerRef = useRef<ConnectController | null>(null);
|
|
25
|
+
if (controllerRef.current === null) {
|
|
26
|
+
controllerRef.current = createConnectController({
|
|
27
|
+
get transport() {
|
|
28
|
+
return optsRef.current.transport;
|
|
29
|
+
},
|
|
30
|
+
get openAuthorizeUrl() {
|
|
31
|
+
return optsRef.current.openAuthorizeUrl;
|
|
32
|
+
},
|
|
33
|
+
get redirectUri() {
|
|
34
|
+
return optsRef.current.redirectUri;
|
|
35
|
+
},
|
|
36
|
+
get platform() {
|
|
37
|
+
return optsRef.current.platform;
|
|
38
|
+
},
|
|
39
|
+
get grantedScope() {
|
|
40
|
+
return optsRef.current.grantedScope;
|
|
41
|
+
},
|
|
42
|
+
get childId() {
|
|
43
|
+
return optsRef.current.childId;
|
|
44
|
+
},
|
|
45
|
+
get ageHint() {
|
|
46
|
+
return optsRef.current.ageHint;
|
|
47
|
+
},
|
|
48
|
+
onSuccess: (r) => optsRef.current.onSuccess?.(r),
|
|
49
|
+
onError: (e) => optsRef.current.onError?.(e),
|
|
50
|
+
onExit: () => optsRef.current.onExit?.(),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const controller = controllerRef.current;
|
|
54
|
+
|
|
55
|
+
const state = useSyncExternalStore(
|
|
56
|
+
controller.subscribe,
|
|
57
|
+
controller.getState,
|
|
58
|
+
controller.getState,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
state,
|
|
63
|
+
open: controller.open,
|
|
64
|
+
selectChild: controller.selectChild,
|
|
65
|
+
retry: controller.retry,
|
|
66
|
+
cancel: controller.cancel,
|
|
67
|
+
};
|
|
68
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConnectFlow (React Native) — unstyled reference implementation. Mirrors
|
|
3
|
+
* web/ConnectFlow.tsx: same states, same honest copy, minimal styling, semantic
|
|
4
|
+
* structure with stable `accessibilityHint` hooks so a PCA can style it entirely.
|
|
5
|
+
*
|
|
6
|
+
* This is the "Layer 2" starting point between the headless hook (useConnect)
|
|
7
|
+
* and the styled drop-in (PhosraConnect.native.tsx). The honesty contract copy
|
|
8
|
+
* ("never a fake green") is preserved; only visual treatment is left to the
|
|
9
|
+
* consumer.
|
|
10
|
+
*/
|
|
11
|
+
import * as React from 'react';
|
|
12
|
+
import { View, Text, Pressable } from 'react-native';
|
|
13
|
+
import { useConnect } from '../core/useConnect.js';
|
|
14
|
+
import type {
|
|
15
|
+
AgeHint,
|
|
16
|
+
AuthorizeOpener,
|
|
17
|
+
BindResult,
|
|
18
|
+
ConnectError,
|
|
19
|
+
ConnectPlatform,
|
|
20
|
+
ConnectRule,
|
|
21
|
+
ConnectTransport,
|
|
22
|
+
} from '../core/types.js';
|
|
23
|
+
import { createNativeAuthorizeOpener } from './openAuthorizeUrl.native.js';
|
|
24
|
+
|
|
25
|
+
export interface ConnectFlowProps {
|
|
26
|
+
platform: ConnectPlatform;
|
|
27
|
+
rules: ConnectRule[];
|
|
28
|
+
grantedScope: string[];
|
|
29
|
+
transport: ConnectTransport;
|
|
30
|
+
redirectUri: string;
|
|
31
|
+
childId?: string;
|
|
32
|
+
ageHint?: AgeHint;
|
|
33
|
+
openAuthorizeUrl?: AuthorizeOpener;
|
|
34
|
+
onSuccess?(r: BindResult): void;
|
|
35
|
+
onError?(e: ConnectError): void;
|
|
36
|
+
onExit?(): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ConnectFlow(props: ConnectFlowProps): React.ReactElement {
|
|
40
|
+
const opener = React.useMemo(
|
|
41
|
+
() => props.openAuthorizeUrl ?? createNativeAuthorizeOpener(),
|
|
42
|
+
[props.openAuthorizeUrl],
|
|
43
|
+
);
|
|
44
|
+
const { state, open, selectChild, retry, cancel } = useConnect({
|
|
45
|
+
transport: props.transport,
|
|
46
|
+
openAuthorizeUrl: opener,
|
|
47
|
+
redirectUri: props.redirectUri,
|
|
48
|
+
platform: props.platform,
|
|
49
|
+
grantedScope: props.grantedScope,
|
|
50
|
+
childId: props.childId,
|
|
51
|
+
ageHint: props.ageHint,
|
|
52
|
+
onSuccess: props.onSuccess,
|
|
53
|
+
onError: props.onError,
|
|
54
|
+
onExit: props.onExit,
|
|
55
|
+
});
|
|
56
|
+
const [showRules, setShowRules] = React.useState(false);
|
|
57
|
+
const { status } = state;
|
|
58
|
+
const name = props.platform.name;
|
|
59
|
+
|
|
60
|
+
if (status === 'idle' && !showRules) {
|
|
61
|
+
return (
|
|
62
|
+
<View accessibilityHint="phosra-connect-intro">
|
|
63
|
+
<Text accessibilityHint="phosra-connect-title">Connect {name}</Text>
|
|
64
|
+
<Text accessibilityHint="phosra-connect-subtitle">
|
|
65
|
+
{'You\'ll go to '}{name}{' to securely approve these safety rules for your child\'s account.'}
|
|
66
|
+
</Text>
|
|
67
|
+
<Text accessibilityHint="phosra-connect-trust">Accredited on the OCSS Trust List.</Text>
|
|
68
|
+
<Text accessibilityHint="phosra-connect-footnote">
|
|
69
|
+
{'Phosra verifies enforcement with '}{name}{' and can\'t read messages.'}
|
|
70
|
+
</Text>
|
|
71
|
+
<Pressable
|
|
72
|
+
accessibilityHint="phosra-connect-continue"
|
|
73
|
+
accessibilityRole="button"
|
|
74
|
+
onPress={() => setShowRules(true)}
|
|
75
|
+
>
|
|
76
|
+
<Text>Continue</Text>
|
|
77
|
+
</Pressable>
|
|
78
|
+
</View>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (status === 'idle' && showRules) {
|
|
83
|
+
return (
|
|
84
|
+
<View accessibilityHint="phosra-connect-rules">
|
|
85
|
+
<Text accessibilityHint="phosra-connect-title">Connect {name}</Text>
|
|
86
|
+
<Text accessibilityHint="phosra-connect-subtitle">
|
|
87
|
+
{'After you connect, '}{name}{' applies and confirms these rules:'}
|
|
88
|
+
</Text>
|
|
89
|
+
<View accessibilityHint="phosra-connect-rule-list">
|
|
90
|
+
{props.rules.map((rule) => (
|
|
91
|
+
<View key={rule.category} accessibilityHint={`phosra-connect-rule-${rule.category}`}>
|
|
92
|
+
<Text>{rule.label}</Text>
|
|
93
|
+
</View>
|
|
94
|
+
))}
|
|
95
|
+
</View>
|
|
96
|
+
<Text accessibilityHint="phosra-connect-footnote">
|
|
97
|
+
{'Shown as "Enforced" only after '}{name}{' confirms — never a fake green.'}
|
|
98
|
+
</Text>
|
|
99
|
+
<Pressable
|
|
100
|
+
accessibilityHint="phosra-connect-back"
|
|
101
|
+
accessibilityRole="button"
|
|
102
|
+
onPress={() => setShowRules(false)}
|
|
103
|
+
>
|
|
104
|
+
<Text>Back</Text>
|
|
105
|
+
</Pressable>
|
|
106
|
+
<Pressable
|
|
107
|
+
accessibilityHint="phosra-connect-continue"
|
|
108
|
+
accessibilityRole="button"
|
|
109
|
+
onPress={() => void open()}
|
|
110
|
+
>
|
|
111
|
+
<Text>Continue</Text>
|
|
112
|
+
</Pressable>
|
|
113
|
+
</View>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (status === 'authorizing' || status === 'exchanging') {
|
|
118
|
+
return (
|
|
119
|
+
<View accessibilityHint="phosra-connect-loading" accessibilityLabel="Loading">
|
|
120
|
+
<Text>
|
|
121
|
+
{status === 'authorizing' ? `Waiting for ${name}…` : 'Finishing up…'}
|
|
122
|
+
</Text>
|
|
123
|
+
</View>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (status === 'selecting') {
|
|
128
|
+
return (
|
|
129
|
+
<View accessibilityHint="phosra-connect-picker">
|
|
130
|
+
<Text accessibilityHint="phosra-connect-title">Choose an account</Text>
|
|
131
|
+
<Text accessibilityHint="phosra-connect-subtitle">
|
|
132
|
+
{'Which '}{name}{' account should these rules protect?'}
|
|
133
|
+
</Text>
|
|
134
|
+
<View accessibilityHint="phosra-connect-profile-list">
|
|
135
|
+
{(state.childProfiles ?? []).map((profile) => (
|
|
136
|
+
<Pressable
|
|
137
|
+
key={profile.id}
|
|
138
|
+
accessibilityHint={`phosra-connect-profile-${profile.id}`}
|
|
139
|
+
accessibilityRole="button"
|
|
140
|
+
onPress={() => void selectChild(profile.id)}
|
|
141
|
+
>
|
|
142
|
+
<Text>{profile.displayName}</Text>
|
|
143
|
+
</Pressable>
|
|
144
|
+
))}
|
|
145
|
+
</View>
|
|
146
|
+
</View>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (status === 'binding') {
|
|
151
|
+
return (
|
|
152
|
+
<View accessibilityHint="phosra-connect-loading" accessibilityLabel="Applying rules">
|
|
153
|
+
<Text>Applying rules on {name}…</Text>
|
|
154
|
+
</View>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (status === 'success') {
|
|
159
|
+
return (
|
|
160
|
+
<View accessibilityHint="phosra-connect-success">
|
|
161
|
+
<Text accessibilityHint="phosra-connect-title">{name} connected</Text>
|
|
162
|
+
<Text accessibilityHint="phosra-connect-subtitle">
|
|
163
|
+
{'These rules are now enforced on '}{name}{'.'}
|
|
164
|
+
</Text>
|
|
165
|
+
<Text accessibilityHint="phosra-connect-verified">Verified on the OCSS Trust List.</Text>
|
|
166
|
+
<Pressable
|
|
167
|
+
accessibilityHint="phosra-connect-done"
|
|
168
|
+
accessibilityRole="button"
|
|
169
|
+
onPress={() => props.onExit?.()}
|
|
170
|
+
>
|
|
171
|
+
<Text>Done</Text>
|
|
172
|
+
</Pressable>
|
|
173
|
+
</View>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (status === 'error') {
|
|
178
|
+
return (
|
|
179
|
+
<View accessibilityHint="phosra-connect-error" accessibilityRole="alert" accessibilityLiveRegion="polite">
|
|
180
|
+
<Text accessibilityHint="phosra-connect-title">
|
|
181
|
+
{'Couldn\'t connect '}{name}
|
|
182
|
+
</Text>
|
|
183
|
+
<Text accessibilityHint="phosra-connect-error-message">
|
|
184
|
+
{state.error?.message ?? 'Something went wrong.'}{' No rules were changed — you can try again.'}
|
|
185
|
+
</Text>
|
|
186
|
+
<Pressable
|
|
187
|
+
accessibilityHint="phosra-connect-retry"
|
|
188
|
+
accessibilityRole="button"
|
|
189
|
+
onPress={() => void retry()}
|
|
190
|
+
>
|
|
191
|
+
<Text>Try again</Text>
|
|
192
|
+
</Pressable>
|
|
193
|
+
<Pressable
|
|
194
|
+
accessibilityHint="phosra-connect-cancel"
|
|
195
|
+
accessibilityRole="button"
|
|
196
|
+
onPress={() => cancel()}
|
|
197
|
+
>
|
|
198
|
+
<Text>Cancel</Text>
|
|
199
|
+
</Pressable>
|
|
200
|
+
</View>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return <View accessibilityHint="phosra-connect-canceled" />;
|
|
205
|
+
}
|