@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.
@@ -0,0 +1,719 @@
1
+ /**
2
+ * PhosraConnect (React Native) — styled drop-in. Mirrors web/PhosraConnect.tsx
3
+ * exactly: same PhosraConnectProps shape, same two intro steps, same states, and
4
+ * the same honesty-contract copy verbatim. Uses react-native primitives +
5
+ * StyleSheet.create with the same design tokens from connect.css.
6
+ *
7
+ * Drop this into a bottom-sheet, modal, or full-screen route in the PCA app.
8
+ * All ceremony logic runs via the shared ConnectController (useConnect hook) —
9
+ * nothing is re-implemented here.
10
+ */
11
+ import * as React from 'react';
12
+ import {
13
+ View,
14
+ Text,
15
+ Pressable,
16
+ ScrollView,
17
+ ActivityIndicator,
18
+ StyleSheet,
19
+ } from 'react-native';
20
+ import { useConnect } from '../core/useConnect.js';
21
+ import type {
22
+ AgeHint,
23
+ AuthorizeOpener,
24
+ BindResult,
25
+ ConnectError,
26
+ ConnectPlatform,
27
+ ConnectRule,
28
+ ConnectTransport,
29
+ } from '../core/types.js';
30
+ import { createNativeAuthorizeOpener } from './openAuthorizeUrl.native.js';
31
+ import {
32
+ Spark,
33
+ PhosraWordmark,
34
+ OcssMark,
35
+ PlatformGlyph,
36
+ CheckIcon,
37
+ ArrowIcon,
38
+ } from './assets.native.js';
39
+
40
+ // ---- Props ---------------------------------------------------------------
41
+
42
+ export interface PhosraConnectProps {
43
+ /** The enforcement platform being connected (e.g. { did, name: 'Snaptr' }). */
44
+ platform: ConnectPlatform;
45
+ /** The safety rules previewed as "what {platform} will apply & confirm". */
46
+ rules: ConnectRule[];
47
+ /** OCSS rule categories the grant authorizes. */
48
+ grantedScope: string[];
49
+ /** The 3 BFF calls (implemented by the PCA around @phosra/link). */
50
+ transport: ConnectTransport;
51
+ /** The PCA-registered deep-link / app-scheme redirect URL (e.g. myapp://cb). */
52
+ redirectUri: string;
53
+ childId?: string;
54
+ ageHint?: AgeHint;
55
+ /** Override the in-app browser opener (defaults to expo-web-browser). */
56
+ openAuthorizeUrl?: AuthorizeOpener;
57
+ /**
58
+ * The platform's own logo node (an Image or react-native-svg component).
59
+ * Defaults to the generic chat-bubble glyph.
60
+ */
61
+ platformGlyph?: React.ReactNode;
62
+ onSuccess?(r: BindResult): void;
63
+ onError?(e: ConnectError): void;
64
+ /** Called when the parent closes / cancels / finishes — the PCA dismisses the sheet. */
65
+ onExit?(): void;
66
+ }
67
+
68
+ // ---- Design tokens (translate of connect.css) ----------------------------
69
+
70
+ const ink = '#0c110e';
71
+ const muted = '#6b7772';
72
+ const faint = '#9aa5a0';
73
+ const line = '#eaedeb';
74
+ const signal = '#00d47e';
75
+ const deep = '#00794a';
76
+ const green600 = '#00b568';
77
+ const paper = '#ffffff';
78
+ const phosraCircle = '#eafbf3';
79
+
80
+ // ---- Main component ------------------------------------------------------
81
+
82
+ /**
83
+ * Styled, drop-in Connect screen for React Native. Renders in the PCA app's
84
+ * own trust boundary (no Phosra-hosted page). Drives the shared ConnectController;
85
+ * shows the honesty contract in-UX (never a fake green: "Enforced"/verified only
86
+ * after the server confirms + verifies to the OCSS root).
87
+ */
88
+ export function PhosraConnect(props: PhosraConnectProps): React.ReactElement {
89
+ const opener = React.useMemo(
90
+ () => props.openAuthorizeUrl ?? createNativeAuthorizeOpener(),
91
+ [props.openAuthorizeUrl],
92
+ );
93
+
94
+ const { state, open, selectChild, retry, cancel } = useConnect({
95
+ transport: props.transport,
96
+ openAuthorizeUrl: opener,
97
+ redirectUri: props.redirectUri,
98
+ platform: props.platform,
99
+ grantedScope: props.grantedScope,
100
+ childId: props.childId,
101
+ ageHint: props.ageHint,
102
+ onSuccess: props.onSuccess,
103
+ onError: props.onError,
104
+ onExit: props.onExit,
105
+ });
106
+
107
+ // Two intro screens (0 = intro + accreditation chip, 1 = rules preview) shown
108
+ // before the ceremony begins. Presentation-only — the controller never sees it.
109
+ const [introStep, setIntroStep] = React.useState<0 | 1>(0);
110
+ const platformGlyph = props.platformGlyph ?? <PlatformGlyph />;
111
+ const status = state.status;
112
+ const inIntro = status === 'idle';
113
+
114
+ const handleClose = () => {
115
+ if (status === 'success' || status === 'canceled') {
116
+ props.onExit?.();
117
+ } else {
118
+ cancel();
119
+ }
120
+ };
121
+
122
+ const handleBack = () => {
123
+ if (inIntro && introStep === 1) {
124
+ setIntroStep(0);
125
+ } else {
126
+ handleClose();
127
+ }
128
+ };
129
+
130
+ // ---- Header (shown on every screen) ------------------------------------
131
+ const Header = (
132
+ <View style={styles.header}>
133
+ <Pressable
134
+ onPress={handleBack}
135
+ style={styles.navButton}
136
+ accessibilityLabel="Back"
137
+ accessibilityRole="button"
138
+ >
139
+ <Text style={styles.navText}>{'‹'}</Text>
140
+ </Pressable>
141
+
142
+ {/* Centered brand — absolutely positioned so it doesn't push the side buttons */}
143
+ <View style={styles.brandCenter} pointerEvents="none">
144
+ <PhosraWordmark height={18} />
145
+ <View style={styles.divider} />
146
+ <View style={styles.ocssRow}>
147
+ <OcssMark size={14} stroke="#37433d" />
148
+ <Text style={styles.ocssLabel}>OCSS</Text>
149
+ </View>
150
+ </View>
151
+
152
+ <Pressable
153
+ onPress={handleClose}
154
+ style={styles.navButton}
155
+ accessibilityLabel="Close"
156
+ accessibilityRole="button"
157
+ >
158
+ <Text style={styles.navText}>{'✕'}</Text>
159
+ </Pressable>
160
+ </View>
161
+ );
162
+
163
+ // ---- Logos row (shared by most screens) --------------------------------
164
+ const Logos = (
165
+ <View style={styles.logosRow}>
166
+ <View style={[styles.logoBubble, styles.logoBubblePhosra]}>
167
+ <Spark size={34} fill={green600} />
168
+ </View>
169
+ <View style={[styles.logoBubble, styles.logoBubblePlatform]}>
170
+ {platformGlyph}
171
+ </View>
172
+ </View>
173
+ );
174
+
175
+ // ---- Screen bodies -----------------------------------------------------
176
+
177
+ let body: React.ReactNode;
178
+ let actions: React.ReactNode = null;
179
+
180
+ if (inIntro && introStep === 0) {
181
+ // Step 0 — intro + accreditation chip
182
+ body = (
183
+ <ScrollView
184
+ style={styles.body}
185
+ contentContainerStyle={styles.bodyContent}
186
+ showsVerticalScrollIndicator={false}
187
+ >
188
+ {Logos}
189
+ <Text style={styles.title}>Connect {props.platform.name}</Text>
190
+ <Text style={styles.sub}>
191
+ {'You\'ll go to '}{props.platform.name}{' to securely approve these safety rules for your child\'s account.'}
192
+ </Text>
193
+ <View style={styles.chipRow}>
194
+ <View style={styles.chip}>
195
+ <OcssMark size={16} stroke={deep} />
196
+ <Text style={styles.chipLabel}>Accredited on the OCSS Trust List</Text>
197
+ </View>
198
+ </View>
199
+ <View style={styles.spacer} />
200
+ <Text style={styles.footnote}>
201
+ {'Phosra verifies enforcement with '}{props.platform.name}{' and can\'t read messages.'}
202
+ </Text>
203
+ </ScrollView>
204
+ );
205
+ actions = (
206
+ <View style={styles.actions}>
207
+ <Pressable
208
+ style={styles.primaryBtn}
209
+ onPress={() => setIntroStep(1)}
210
+ accessibilityRole="button"
211
+ >
212
+ <Text style={styles.primaryBtnText}>Continue</Text>
213
+ <ArrowIcon size={15} />
214
+ </Pressable>
215
+ </View>
216
+ );
217
+ } else if (inIntro && introStep === 1) {
218
+ // Step 1 — rules preview
219
+ body = (
220
+ <ScrollView
221
+ style={styles.body}
222
+ contentContainerStyle={styles.bodyContent}
223
+ showsVerticalScrollIndicator={false}
224
+ >
225
+ {Logos}
226
+ <Text style={styles.title}>Connect {props.platform.name}</Text>
227
+ <Text style={styles.sub}>
228
+ {'After you connect, '}
229
+ <Text style={styles.subBold}>{props.platform.name}{' applies and confirms'}</Text>
230
+ {' these rules:'}
231
+ </Text>
232
+ <View style={styles.rulesList}>
233
+ <Text style={styles.eyebrow}>
234
+ {'APPLIED & VERIFIED ON '}{props.platform.name.toUpperCase()}
235
+ </Text>
236
+ {props.rules.map((rule, idx) => (
237
+ <View
238
+ key={rule.category}
239
+ style={[styles.ruleRow, idx === 0 && styles.ruleRowFirst]}
240
+ >
241
+ <View style={styles.tick}>
242
+ <CheckIcon />
243
+ </View>
244
+ <Text style={styles.ruleLabel}>{rule.label}</Text>
245
+ </View>
246
+ ))}
247
+ </View>
248
+ <View style={styles.spacer} />
249
+ <Text style={styles.footnote}>
250
+ {'Shown as "Enforced" only after '}{props.platform.name}{' confirms — never a fake green.'}
251
+ </Text>
252
+ </ScrollView>
253
+ );
254
+ actions = (
255
+ <View style={styles.actions}>
256
+ <Pressable
257
+ style={styles.primaryBtn}
258
+ onPress={() => void open()}
259
+ accessibilityRole="button"
260
+ >
261
+ <Text style={styles.primaryBtnText}>Continue</Text>
262
+ <ArrowIcon size={15} />
263
+ </Pressable>
264
+ </View>
265
+ );
266
+ } else if (status === 'authorizing' || status === 'exchanging') {
267
+ body = (
268
+ <View style={styles.body}>
269
+ {Logos}
270
+ <View style={styles.statusBlock}>
271
+ <ActivityIndicator color={green600} size="large" />
272
+ <Text style={styles.statusLabel}>
273
+ {status === 'authorizing'
274
+ ? `Waiting for ${props.platform.name}…`
275
+ : 'Finishing up…'}
276
+ </Text>
277
+ </View>
278
+ <View style={styles.spacer} />
279
+ </View>
280
+ );
281
+ } else if (status === 'selecting') {
282
+ body = (
283
+ <View style={styles.body}>
284
+ {Logos}
285
+ <Text style={styles.title}>Choose an account</Text>
286
+ <Text style={styles.sub}>
287
+ {'Which '}{props.platform.name}{' account should these rules protect?'}
288
+ </Text>
289
+ <View style={styles.picker}>
290
+ {(state.childProfiles ?? []).map((profile, idx) => (
291
+ <Pressable
292
+ key={profile.id}
293
+ style={[styles.pickerRow, idx === 0 && styles.pickerRowFirst]}
294
+ onPress={() => void selectChild(profile.id)}
295
+ accessibilityRole="button"
296
+ >
297
+ <Text style={styles.pickerRowText}>{profile.displayName}</Text>
298
+ <Text style={styles.pickerChevron}>{'›'}</Text>
299
+ </Pressable>
300
+ ))}
301
+ </View>
302
+ <View style={styles.spacer} />
303
+ </View>
304
+ );
305
+ } else if (status === 'binding') {
306
+ body = (
307
+ <View style={styles.body}>
308
+ {Logos}
309
+ <View style={styles.statusBlock}>
310
+ <ActivityIndicator color={green600} size="large" />
311
+ <Text style={styles.statusLabel}>
312
+ {'Applying rules on '}{props.platform.name}{'…'}
313
+ </Text>
314
+ </View>
315
+ <View style={styles.spacer} />
316
+ </View>
317
+ );
318
+ } else if (status === 'success') {
319
+ body = (
320
+ <View style={styles.body}>
321
+ <View style={styles.successBlock}>
322
+ <View style={styles.successBadge}>
323
+ <CheckIcon size={26} stroke={deep} />
324
+ </View>
325
+ <Text style={styles.successTitle}>{props.platform.name}{' connected'}</Text>
326
+ <Text style={styles.sub}>
327
+ {'These rules are now enforced on '}{props.platform.name}{'.'}
328
+ </Text>
329
+ <View style={styles.verifiedRow}>
330
+ <OcssMark size={14} stroke={deep} />
331
+ <Text style={styles.verifiedLabel}>{'Verified on the OCSS Trust List'}</Text>
332
+ </View>
333
+ </View>
334
+ <View style={styles.spacer} />
335
+ </View>
336
+ );
337
+ actions = (
338
+ <View style={styles.actions}>
339
+ <Pressable
340
+ style={styles.primaryBtn}
341
+ onPress={() => props.onExit?.()}
342
+ accessibilityRole="button"
343
+ >
344
+ <Text style={styles.primaryBtnText}>Done</Text>
345
+ </Pressable>
346
+ </View>
347
+ );
348
+ } else if (status === 'error') {
349
+ body = (
350
+ <View style={styles.body}>
351
+ {Logos}
352
+ <View style={styles.errorBlock} accessibilityRole="alert" accessibilityLiveRegion="polite">
353
+ <Text style={styles.errorTitle}>
354
+ {'Couldn\'t connect '}{props.platform.name}
355
+ </Text>
356
+ <Text style={styles.errorMsg}>
357
+ {state.error?.message ?? 'Something went wrong.'}{' No rules were changed — you can try again.'}
358
+ </Text>
359
+ </View>
360
+ <View style={styles.spacer} />
361
+ </View>
362
+ );
363
+ actions = (
364
+ <View style={styles.actions}>
365
+ <Pressable
366
+ style={styles.primaryBtn}
367
+ onPress={() => void retry()}
368
+ accessibilityRole="button"
369
+ >
370
+ <Text style={styles.primaryBtnText}>Try again</Text>
371
+ </Pressable>
372
+ <Pressable
373
+ style={styles.ghostBtn}
374
+ onPress={handleClose}
375
+ accessibilityRole="button"
376
+ >
377
+ <Text style={styles.ghostBtnText}>Cancel</Text>
378
+ </Pressable>
379
+ </View>
380
+ );
381
+ } else {
382
+ // canceled
383
+ body = <View style={styles.body} />;
384
+ }
385
+
386
+ return (
387
+ <View style={styles.root}>
388
+ {Header}
389
+ {body}
390
+ {actions}
391
+ </View>
392
+ );
393
+ }
394
+
395
+ // ---- Styles (translate of connect.css tokens) ----------------------------
396
+
397
+ const styles = StyleSheet.create({
398
+ // Root container
399
+ root: {
400
+ flex: 1,
401
+ backgroundColor: paper,
402
+ paddingHorizontal: 26,
403
+ paddingTop: 14,
404
+ paddingBottom: 14,
405
+ },
406
+
407
+ // Header
408
+ header: {
409
+ flexDirection: 'row',
410
+ alignItems: 'center',
411
+ justifyContent: 'space-between',
412
+ height: 46,
413
+ position: 'relative',
414
+ },
415
+ navButton: {
416
+ width: 26,
417
+ height: 26,
418
+ alignItems: 'center',
419
+ justifyContent: 'center',
420
+ },
421
+ navText: {
422
+ fontSize: 22,
423
+ lineHeight: 26,
424
+ color: ink,
425
+ fontWeight: '400',
426
+ },
427
+ brandCenter: {
428
+ position: 'absolute',
429
+ left: 0,
430
+ right: 0,
431
+ flexDirection: 'row',
432
+ alignItems: 'center',
433
+ justifyContent: 'center',
434
+ gap: 9,
435
+ },
436
+ divider: {
437
+ width: 1,
438
+ height: 15,
439
+ backgroundColor: '#d7ddd9',
440
+ },
441
+ ocssRow: {
442
+ flexDirection: 'row',
443
+ alignItems: 'center',
444
+ gap: 4,
445
+ opacity: 0.72,
446
+ },
447
+ ocssLabel: {
448
+ fontSize: 10,
449
+ fontWeight: '800',
450
+ letterSpacing: 1.2,
451
+ color: '#37433d',
452
+ },
453
+
454
+ // Body
455
+ body: {
456
+ flex: 1,
457
+ },
458
+ bodyContent: {
459
+ flexGrow: 1,
460
+ },
461
+
462
+ // Logos
463
+ logosRow: {
464
+ flexDirection: 'row',
465
+ justifyContent: 'center',
466
+ alignItems: 'center',
467
+ marginTop: 48,
468
+ },
469
+ logoBubble: {
470
+ width: 64,
471
+ height: 64,
472
+ borderRadius: 32,
473
+ alignItems: 'center',
474
+ justifyContent: 'center',
475
+ },
476
+ logoBubblePhosra: {
477
+ backgroundColor: phosraCircle,
478
+ },
479
+ logoBubblePlatform: {
480
+ backgroundColor: paper,
481
+ borderWidth: 1.5,
482
+ borderColor: line,
483
+ marginLeft: -14,
484
+ shadowColor: '#101820',
485
+ shadowOffset: { width: 0, height: 3 },
486
+ shadowOpacity: 0.09,
487
+ shadowRadius: 6,
488
+ elevation: 3,
489
+ },
490
+
491
+ // Typography
492
+ title: {
493
+ textAlign: 'center',
494
+ fontSize: 25,
495
+ fontWeight: '800',
496
+ letterSpacing: -0.5,
497
+ color: ink,
498
+ marginTop: 30,
499
+ },
500
+ sub: {
501
+ textAlign: 'center',
502
+ fontSize: 15,
503
+ lineHeight: 22,
504
+ color: muted,
505
+ marginTop: 12,
506
+ paddingHorizontal: 6,
507
+ },
508
+ subBold: {
509
+ color: ink,
510
+ fontWeight: '700',
511
+ },
512
+
513
+ // Accreditation chip
514
+ chipRow: {
515
+ flexDirection: 'row',
516
+ justifyContent: 'center',
517
+ marginTop: 20,
518
+ },
519
+ chip: {
520
+ flexDirection: 'row',
521
+ alignItems: 'center',
522
+ gap: 7,
523
+ paddingVertical: 7,
524
+ paddingHorizontal: 14,
525
+ borderWidth: 1,
526
+ borderColor: '#d7ece1',
527
+ backgroundColor: '#f3fbf7',
528
+ borderRadius: 22,
529
+ },
530
+ chipLabel: {
531
+ fontSize: 12,
532
+ fontWeight: '700',
533
+ color: deep,
534
+ },
535
+
536
+ // Rules list
537
+ rulesList: {
538
+ borderWidth: 1,
539
+ borderColor: line,
540
+ borderRadius: 16,
541
+ marginTop: 28,
542
+ overflow: 'hidden',
543
+ },
544
+ eyebrow: {
545
+ fontSize: 11,
546
+ fontWeight: '800',
547
+ letterSpacing: 1.2,
548
+ color: muted,
549
+ paddingTop: 15,
550
+ paddingBottom: 11,
551
+ paddingHorizontal: 18,
552
+ },
553
+ ruleRow: {
554
+ flexDirection: 'row',
555
+ alignItems: 'center',
556
+ gap: 13,
557
+ paddingVertical: 14,
558
+ paddingHorizontal: 18,
559
+ borderTopWidth: 1,
560
+ borderTopColor: line,
561
+ },
562
+ ruleRowFirst: {
563
+ borderTopWidth: 1,
564
+ borderTopColor: line,
565
+ },
566
+ tick: {
567
+ width: 22,
568
+ height: 22,
569
+ borderRadius: 6,
570
+ backgroundColor: '#e7f5ee',
571
+ alignItems: 'center',
572
+ justifyContent: 'center',
573
+ },
574
+ ruleLabel: {
575
+ fontSize: 15,
576
+ fontWeight: '600',
577
+ color: ink,
578
+ flex: 1,
579
+ },
580
+
581
+ // Child picker
582
+ picker: {
583
+ marginTop: 26,
584
+ borderWidth: 1,
585
+ borderColor: line,
586
+ borderRadius: 16,
587
+ overflow: 'hidden',
588
+ },
589
+ pickerRow: {
590
+ flexDirection: 'row',
591
+ alignItems: 'center',
592
+ justifyContent: 'space-between',
593
+ paddingVertical: 16,
594
+ paddingHorizontal: 18,
595
+ borderTopWidth: 1,
596
+ borderTopColor: line,
597
+ backgroundColor: paper,
598
+ },
599
+ pickerRowFirst: {
600
+ borderTopWidth: 0,
601
+ },
602
+ pickerRowText: {
603
+ fontSize: 16,
604
+ fontWeight: '600',
605
+ color: ink,
606
+ },
607
+ pickerChevron: {
608
+ fontSize: 20,
609
+ color: faint,
610
+ },
611
+
612
+ // Status / spinner
613
+ statusBlock: {
614
+ alignItems: 'center',
615
+ gap: 14,
616
+ marginTop: 40,
617
+ },
618
+ statusLabel: {
619
+ fontSize: 15,
620
+ fontWeight: '600',
621
+ color: muted,
622
+ marginTop: 14,
623
+ },
624
+
625
+ // Success
626
+ successBlock: {
627
+ alignItems: 'center',
628
+ gap: 14,
629
+ marginTop: 34,
630
+ },
631
+ successBadge: {
632
+ width: 62,
633
+ height: 62,
634
+ borderRadius: 31,
635
+ backgroundColor: phosraCircle,
636
+ alignItems: 'center',
637
+ justifyContent: 'center',
638
+ },
639
+ successTitle: {
640
+ fontSize: 23,
641
+ fontWeight: '800',
642
+ letterSpacing: -0.4,
643
+ color: ink,
644
+ textAlign: 'center',
645
+ },
646
+ verifiedRow: {
647
+ flexDirection: 'row',
648
+ alignItems: 'center',
649
+ gap: 6,
650
+ },
651
+ verifiedLabel: {
652
+ fontSize: 12,
653
+ fontWeight: '700',
654
+ color: deep,
655
+ },
656
+
657
+ // Error
658
+ errorBlock: {
659
+ alignItems: 'center',
660
+ gap: 10,
661
+ marginTop: 40,
662
+ },
663
+ errorTitle: {
664
+ fontSize: 19,
665
+ fontWeight: '800',
666
+ color: ink,
667
+ textAlign: 'center',
668
+ },
669
+ errorMsg: {
670
+ fontSize: 14,
671
+ color: muted,
672
+ lineHeight: 21,
673
+ textAlign: 'center',
674
+ maxWidth: 300,
675
+ },
676
+
677
+ // Footer
678
+ spacer: {
679
+ flex: 1,
680
+ minHeight: 20,
681
+ },
682
+ footnote: {
683
+ textAlign: 'center',
684
+ fontSize: 11,
685
+ color: faint,
686
+ lineHeight: 16,
687
+ paddingHorizontal: 16,
688
+ paddingBottom: 11,
689
+ },
690
+
691
+ // Actions
692
+ actions: {
693
+ gap: 8,
694
+ },
695
+ primaryBtn: {
696
+ backgroundColor: signal,
697
+ borderRadius: 16,
698
+ height: 56,
699
+ flexDirection: 'row',
700
+ alignItems: 'center',
701
+ justifyContent: 'center',
702
+ gap: 8,
703
+ },
704
+ primaryBtnText: {
705
+ fontSize: 17,
706
+ fontWeight: '800',
707
+ color: '#04150d',
708
+ },
709
+ ghostBtn: {
710
+ height: 44,
711
+ alignItems: 'center',
712
+ justifyContent: 'center',
713
+ },
714
+ ghostBtnText: {
715
+ fontSize: 15,
716
+ fontWeight: '700',
717
+ color: muted,
718
+ },
719
+ });