@pyric/ui 0.1.0-alpha.10 → 0.1.0-alpha.12

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 (152) hide show
  1. package/package.json +5 -4
  2. package/src/agents/ContextWindowUsage.tsx +514 -0
  3. package/src/agents/EmptyState.tsx +27 -0
  4. package/src/agents/Fold.tsx +64 -0
  5. package/src/agents/Modal.tsx +65 -0
  6. package/src/agents/PulsingDot.tsx +28 -0
  7. package/src/agents/inbrowser-agent-usage.d.ts +141 -0
  8. package/src/agents/index.ts +28 -0
  9. package/src/auth/authApi.ts +72 -0
  10. package/src/auth/claims.ts +63 -0
  11. package/src/auth/components/AuthProviderToggles.tsx +147 -0
  12. package/src/auth/components/AuthSignInHelper.tsx +197 -0
  13. package/src/auth/components/AuthUserForm.tsx +328 -0
  14. package/src/auth/components/AuthUserList.tsx +219 -0
  15. package/src/auth/components/ClaimsField.tsx +50 -0
  16. package/src/auth/components/confirmActions.tsx +114 -0
  17. package/src/auth/controller.ts +173 -0
  18. package/src/auth/hooks/index.ts +36 -0
  19. package/src/auth/hooks/useAuthFlowHelper.ts +55 -0
  20. package/src/auth/hooks/useAuthProviderConfig.ts +139 -0
  21. package/src/auth/hooks/useAuthUserEditor.ts +76 -0
  22. package/src/auth/hooks/useAuthUsers.ts +154 -0
  23. package/src/auth/index.ts +42 -0
  24. package/src/auth/providers.ts +28 -0
  25. package/src/auth/reducers/userEditor.ts +186 -0
  26. package/src/events/components/ActivityActionItems.tsx +136 -0
  27. package/src/events/components/ActivityGrid.tsx +197 -0
  28. package/src/events/components/ActivityGridRow.tsx +65 -0
  29. package/src/events/components/ProposedChangeDiff.tsx +175 -0
  30. package/src/events/components/format.ts +21 -0
  31. package/src/events/components/index.ts +17 -0
  32. package/src/events/digest.ts +630 -0
  33. package/src/events/hooks/index.ts +9 -0
  34. package/src/events/hooks/useActivityDigest.ts +42 -0
  35. package/src/events/hooks/useActivityStream.ts +86 -0
  36. package/src/events/index.ts +39 -0
  37. package/src/events/types.ts +152 -0
  38. package/src/firestore/components/CollectionList.tsx +78 -0
  39. package/src/firestore/components/DeleteWithConfirm.tsx +88 -0
  40. package/src/firestore/components/DocumentEditor.tsx +350 -0
  41. package/src/firestore/components/DocumentList.tsx +217 -0
  42. package/src/firestore/components/DocumentPreview.tsx +265 -0
  43. package/src/firestore/components/FieldRenderer.tsx +25 -0
  44. package/src/firestore/components/QueryBuilder.tsx +181 -0
  45. package/src/firestore/components/ReferencePicker.tsx +212 -0
  46. package/src/firestore/components/TreeEntry.tsx +58 -0
  47. package/src/firestore/components/context.ts +25 -0
  48. package/src/firestore/fieldEditors/array.tsx +30 -0
  49. package/src/firestore/fieldEditors/boolean.tsx +39 -0
  50. package/src/firestore/fieldEditors/bytes.tsx +53 -0
  51. package/src/firestore/fieldEditors/geopoint.tsx +71 -0
  52. package/src/firestore/fieldEditors/map.tsx +33 -0
  53. package/src/firestore/fieldEditors/null.tsx +27 -0
  54. package/src/firestore/fieldEditors/number.tsx +43 -0
  55. package/src/firestore/fieldEditors/reference.tsx +87 -0
  56. package/src/firestore/fieldEditors/registry.ts +45 -0
  57. package/src/firestore/fieldEditors/string.tsx +33 -0
  58. package/src/firestore/fieldEditors/timestamp.tsx +75 -0
  59. package/src/firestore/fieldEditors/types.ts +68 -0
  60. package/src/firestore/fieldEditors/vector.tsx +142 -0
  61. package/src/firestore/firestoreApi.ts +86 -0
  62. package/src/firestore/hooks/coerceError.ts +34 -0
  63. package/src/firestore/hooks/index.ts +46 -0
  64. package/src/firestore/hooks/useCollectionList.ts +102 -0
  65. package/src/firestore/hooks/useDocumentEditor.ts +161 -0
  66. package/src/firestore/hooks/useDocumentList.ts +242 -0
  67. package/src/firestore/hooks/useDocumentSubcollections.ts +86 -0
  68. package/src/firestore/hooks/useFirestoreCollection.ts +51 -0
  69. package/src/firestore/hooks/useFirestoreDoc.ts +55 -0
  70. package/src/firestore/hooks/useQueryBuilder.ts +188 -0
  71. package/src/firestore/hooks/useRecursiveDelete.ts +77 -0
  72. package/src/firestore/hooks/useReferencePicker.ts +228 -0
  73. package/src/firestore/import/parseImport.ts +137 -0
  74. package/src/firestore/index.ts +87 -0
  75. package/src/firestore/reducers/defaults.ts +38 -0
  76. package/src/firestore/reducers/documentEditor.ts +239 -0
  77. package/src/firestore/reducers/tree.ts +140 -0
  78. package/src/firestore/reducers/types.ts +92 -0
  79. package/src/firestore/reducers/validation.ts +129 -0
  80. package/src/firestore/types.ts +231 -0
  81. package/src/firestore/validation/ids.ts +45 -0
  82. package/src/firestore/valueEquality.ts +43 -0
  83. package/src/index.ts +10 -0
  84. package/src/primitives/Badge.tsx +40 -0
  85. package/src/primitives/ConfirmDialog.tsx +137 -0
  86. package/src/primitives/CopyButton.tsx +62 -0
  87. package/src/primitives/JsonView.tsx +151 -0
  88. package/src/primitives/SegmentedControl.tsx +72 -0
  89. package/src/primitives/Toast.tsx +161 -0
  90. package/src/primitives/VirtualList.tsx +104 -0
  91. package/src/primitives/hooks/useContainerSize.ts +53 -0
  92. package/src/primitives/hooks/useUpdateHighlights.ts +109 -0
  93. package/src/primitives/index.ts +39 -0
  94. package/src/primitives/useConfirm.tsx +113 -0
  95. package/src/rtdb/components/RtdbPathBar.tsx +135 -0
  96. package/src/rtdb/components/RtdbTree.tsx +409 -0
  97. package/src/rtdb/editor.ts +79 -0
  98. package/src/rtdb/hooks/useRtdbTree.ts +137 -0
  99. package/src/rtdb/index.ts +66 -0
  100. package/src/rtdb/pathInput.ts +47 -0
  101. package/src/rtdb/reducers/tree.ts +191 -0
  102. package/src/rtdb/rtdbApi.ts +23 -0
  103. package/src/rtdb/values.ts +188 -0
  104. package/src/rules/components/DenialInspector.tsx +227 -0
  105. package/src/rules/components/format.ts +171 -0
  106. package/src/rules/components/index.ts +12 -0
  107. package/src/rules/components/scope.ts +75 -0
  108. package/src/rules/hooks/index.ts +5 -0
  109. package/src/rules/hooks/useDenialTrace.ts +100 -0
  110. package/src/rules/index.ts +24 -0
  111. package/src/rules/types.ts +91 -0
  112. package/src/storage/collisionRename.ts +114 -0
  113. package/src/storage/components/DeleteSelectionWithConfirm.tsx +193 -0
  114. package/src/storage/components/ObjectBrowser.tsx +219 -0
  115. package/src/storage/components/ObjectInspector.tsx +169 -0
  116. package/src/storage/components/PathBreadcrumb.tsx +84 -0
  117. package/src/storage/components/UploadDropzone.tsx +182 -0
  118. package/src/storage/folderPlaceholder.ts +40 -0
  119. package/src/storage/hooks/index.ts +59 -0
  120. package/src/storage/hooks/useMetadataEditor.ts +329 -0
  121. package/src/storage/hooks/useObjectUpload.ts +262 -0
  122. package/src/storage/hooks/usePathState.ts +94 -0
  123. package/src/storage/hooks/useStorageDelete.ts +195 -0
  124. package/src/storage/hooks/useStorageList.ts +261 -0
  125. package/src/storage/hooks/useStorageObject.ts +162 -0
  126. package/src/storage/hooks/useStorageRulesGate.ts +270 -0
  127. package/src/storage/hooks/useStorageSelection.ts +90 -0
  128. package/src/storage/index.ts +59 -0
  129. package/src/storage/pendingPrefixes.ts +125 -0
  130. package/src/storage/previews.tsx +120 -0
  131. package/src/storage/storageApi.ts +54 -0
  132. package/src/traffic/components/RuleHeatmap.tsx +97 -0
  133. package/src/traffic/components/TrafficDetail.tsx +160 -0
  134. package/src/traffic/components/TrafficGroupRow.tsx +91 -0
  135. package/src/traffic/components/TrafficLineChart.tsx +139 -0
  136. package/src/traffic/components/TrafficLog.tsx +175 -0
  137. package/src/traffic/components/TrafficMetricCards.tsx +77 -0
  138. package/src/traffic/components/TrafficRow.tsx +69 -0
  139. package/src/traffic/components/TrafficStats.tsx +73 -0
  140. package/src/traffic/components/TrafficTimeline.tsx +289 -0
  141. package/src/traffic/components/format.ts +22 -0
  142. package/src/traffic/components/index.ts +22 -0
  143. package/src/traffic/hooks/index.ts +60 -0
  144. package/src/traffic/hooks/useRuleHeatmap.ts +92 -0
  145. package/src/traffic/hooks/useTrafficBuckets.ts +146 -0
  146. package/src/traffic/hooks/useTrafficFilter.ts +74 -0
  147. package/src/traffic/hooks/useTrafficGroups.ts +126 -0
  148. package/src/traffic/hooks/useTrafficMetrics.ts +250 -0
  149. package/src/traffic/hooks/useTrafficMonitor.ts +111 -0
  150. package/src/traffic/hooks/useTrafficStats.ts +77 -0
  151. package/src/traffic/index.ts +13 -0
  152. package/src/traffic/types.ts +85 -0
@@ -0,0 +1,114 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { AuthUserRecord } from 'pyric/auth';
3
+ import { useConfirm } from '../../primitives/useConfirm.js';
4
+
5
+ /** Shared trigger contract for the confirm-gated destructive actions. */
6
+ interface TriggerProps {
7
+ onClick: () => void;
8
+ }
9
+
10
+ export interface DeleteUserWithConfirmProps {
11
+ user: AuthUserRecord;
12
+ /** Runs after the user confirms. Wire to `useAuthUsers().deleteUser`. */
13
+ onDelete: (uid: string) => void;
14
+ title?: string;
15
+ body?: ReactNode;
16
+ confirmLabel?: string;
17
+ /** Trigger override; default is a plain destructive `<button>`. */
18
+ renderTrigger?: (props: TriggerProps) => ReactNode;
19
+ className?: string;
20
+ }
21
+
22
+ function identifierOf(user: AuthUserRecord): string {
23
+ return user.email ?? user.phoneNumber ?? user.displayName ?? user.uid;
24
+ }
25
+
26
+ /**
27
+ * Confirm-gated single-user delete (the emulator UI's row-menu
28
+ * "Delete user"). Requires a `<ConfirmProvider>` ancestor.
29
+ */
30
+ export function DeleteUserWithConfirm({
31
+ user,
32
+ onDelete,
33
+ title = 'Delete user',
34
+ body,
35
+ confirmLabel = 'Delete',
36
+ renderTrigger,
37
+ className,
38
+ }: DeleteUserWithConfirmProps) {
39
+ const confirm = useConfirm();
40
+ const handleClick = async () => {
41
+ const ok = await confirm({
42
+ title,
43
+ body: body ?? `This will permanently delete ${identifierOf(user)}.`,
44
+ destructive: true,
45
+ confirmLabel,
46
+ });
47
+ if (ok) onDelete(user.uid);
48
+ };
49
+ if (renderTrigger) return <>{renderTrigger({ onClick: handleClick })}</>;
50
+ return (
51
+ <button
52
+ type="button"
53
+ className={className}
54
+ data-pyric-ui="delete-user"
55
+ data-pyric-destructive
56
+ onClick={handleClick}
57
+ >
58
+ Delete user
59
+ </button>
60
+ );
61
+ }
62
+
63
+ export interface ClearUsersWithConfirmProps {
64
+ /** Runs after the user confirms. Wire to `useAuthUsers().clearUsers`. */
65
+ onClear: () => void;
66
+ /** Current user count, interpolated into the default body. */
67
+ count?: number;
68
+ title?: string;
69
+ body?: ReactNode;
70
+ confirmLabel?: string;
71
+ renderTrigger?: (props: TriggerProps) => ReactNode;
72
+ className?: string;
73
+ }
74
+
75
+ /**
76
+ * Confirm-gated clear-all (the emulator UI's "Clear all data").
77
+ * Requires a `<ConfirmProvider>` ancestor.
78
+ */
79
+ export function ClearUsersWithConfirm({
80
+ onClear,
81
+ count,
82
+ title = 'Clear all users',
83
+ body,
84
+ confirmLabel = 'Clear',
85
+ renderTrigger,
86
+ className,
87
+ }: ClearUsersWithConfirmProps) {
88
+ const confirm = useConfirm();
89
+ const handleClick = async () => {
90
+ const ok = await confirm({
91
+ title,
92
+ body:
93
+ body ??
94
+ (count != null
95
+ ? `This will permanently delete all ${count} users.`
96
+ : 'This will permanently delete every user.'),
97
+ destructive: true,
98
+ confirmLabel,
99
+ });
100
+ if (ok) onClear();
101
+ };
102
+ if (renderTrigger) return <>{renderTrigger({ onClick: handleClick })}</>;
103
+ return (
104
+ <button
105
+ type="button"
106
+ className={className}
107
+ data-pyric-ui="clear-users"
108
+ data-pyric-destructive
109
+ onClick={handleClick}
110
+ >
111
+ Clear all users
112
+ </button>
113
+ );
114
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Sign-in helper controller — the host side of `pyric/auth`'s
3
+ * `AuthFlowResolver` seam.
4
+ *
5
+ * `pyric/auth` stays UI-free: `signInWithPopup` / `signInWithRedirect`
6
+ * delegate to a resolver. This controller IS that resolver's host
7
+ * implementation — it parks the SDK's promise, drives a host UI (an
8
+ * account picker + add-account form, e.g. `<AuthSignInHelper>`), and
9
+ * settles the promise when the user picks an identity, adds one, or
10
+ * cancels. React components stay thin presentational shells over this;
11
+ * all the settle/seed logic lives here so it's testable without a DOM.
12
+ *
13
+ * Faithful to the Firebase emulator: "add account" mints a sandbox
14
+ * identity with optional custom claims (the emulator's
15
+ * `customAttributes`), so a rule gated on `request.auth.token.<claim>`
16
+ * works against live sandbox traffic. Credential + token synthesis is
17
+ * backend-owned (`sandbox.createSignInCredential`) — this controller
18
+ * only drives the UI flow and settles the parked promise.
19
+ */
20
+ import {
21
+ sandbox as authSandbox,
22
+ type Auth,
23
+ type AuthFlowRequest,
24
+ type AuthFlowResolver,
25
+ type UserCredential,
26
+ } from 'pyric/auth';
27
+
28
+ /** A field set for "add new account" — mirrors the emulator's add-user form. */
29
+ export interface NewIdentitySpec {
30
+ email: string;
31
+ displayName?: string;
32
+ /** Parsed custom claims (the emulator's `customAttributes`). */
33
+ customClaims?: Record<string, unknown>;
34
+ }
35
+
36
+ /** One pickable identity, as reported by `sandbox.listIdentities`. */
37
+ export type SandboxIdentity = ReturnType<typeof authSandbox.listIdentities>[number];
38
+
39
+ /** Snapshot the helper UI renders from. */
40
+ export interface HelperState {
41
+ /** The in-flight request, or null when the helper is closed. */
42
+ request: AuthFlowRequest | null;
43
+ /** Existing identities to pick from (seeded + previously created). */
44
+ identities: SandboxIdentity[];
45
+ }
46
+
47
+ type Pending = {
48
+ req: AuthFlowRequest;
49
+ resolve: (c: UserCredential) => void;
50
+ reject: (e: unknown) => void;
51
+ };
52
+
53
+ export class AuthFlowController {
54
+ private pending: Pending | null = null;
55
+ private readonly listeners = new Set<() => void>();
56
+ /** Memoized {@link snapshot} result, invalidated by {@link emit}.
57
+ * `useSyncExternalStore` compares consecutive `getSnapshot()` results
58
+ * with `Object.is` — an uncached object here re-renders forever. */
59
+ private cached: HelperState | null = null;
60
+
61
+ constructor(private readonly auth: Auth) {}
62
+
63
+ /** Wire this controller's resolver into the auth handle. Paired with
64
+ * {@link uninstall} for use in a React effect (install in the body,
65
+ * uninstall in the cleanup) — StrictMode-safe. */
66
+ install(): void {
67
+ authSandbox.setAuthFlowResolver(this.auth, this.resolver());
68
+ }
69
+
70
+ uninstall(): void {
71
+ authSandbox.setAuthFlowResolver(this.auth, null);
72
+ }
73
+
74
+ /** The resolver to hand to `sandbox.setAuthFlowResolver`. Popup and
75
+ * redirect share one implementation (the sandbox has no navigation). */
76
+ resolver(): AuthFlowResolver {
77
+ const open = (req: AuthFlowRequest): Promise<UserCredential> =>
78
+ new Promise<UserCredential>((resolve, reject) => {
79
+ // One helper at a time: a new request supersedes any stale pending.
80
+ if (this.pending) this.cancel();
81
+ this.pending = { req, resolve, reject };
82
+ this.emit();
83
+ });
84
+ return { openPopup: open, openRedirect: open };
85
+ }
86
+
87
+ // ─── React glue (subscribe + snapshot) ──────────────────────────────
88
+ subscribe(fn: () => void): () => void {
89
+ this.listeners.add(fn);
90
+ return () => this.listeners.delete(fn);
91
+ }
92
+
93
+ snapshot(): HelperState {
94
+ this.cached ??= {
95
+ request: this.pending?.req ?? null,
96
+ identities: authSandbox.listIdentities(this.auth),
97
+ };
98
+ return this.cached;
99
+ }
100
+
101
+ private emit(): void {
102
+ this.cached = null;
103
+ for (const l of this.listeners) l();
104
+ }
105
+
106
+ // ─── UI actions ─────────────────────────────────────────────────────
107
+ /** Pick an existing identity (by uid). The backend mints the credential
108
+ * (and records the provider on the identity). */
109
+ pick(uid: string): void {
110
+ const pending = this.pending;
111
+ if (!pending) return;
112
+ let cred: UserCredential;
113
+ try {
114
+ cred = authSandbox.createSignInCredential(this.auth, {
115
+ providerId: pending.req.providerId,
116
+ uid,
117
+ });
118
+ } catch (e) {
119
+ this.take()?.reject(e);
120
+ return;
121
+ }
122
+ this.take()?.resolve(cred);
123
+ }
124
+
125
+ /** Add + sign in as a new identity. The backend creates the identity
126
+ * (so claims resolve in rules and it shows up in the picker next time)
127
+ * and mints the credential in one step.
128
+ *
129
+ * Credential creation happens BEFORE {@link take}'s emit: subscribers
130
+ * recompute the snapshot synchronously on emit, so creating after would
131
+ * publish a stale identity list (a `useSyncExternalStore` consumer
132
+ * would miss the new account until the next unrelated emit). */
133
+ add(spec: NewIdentitySpec): void {
134
+ const pending = this.pending;
135
+ if (!pending) return;
136
+ let cred: UserCredential;
137
+ try {
138
+ cred = authSandbox.createSignInCredential(this.auth, {
139
+ providerId: pending.req.providerId,
140
+ spec: {
141
+ email: spec.email,
142
+ displayName: spec.displayName,
143
+ customClaims: spec.customClaims,
144
+ },
145
+ });
146
+ } catch (e) {
147
+ this.take()?.reject(e);
148
+ return;
149
+ }
150
+ this.take()?.resolve(cred);
151
+ }
152
+
153
+ /** Dismiss — rejects with the faithful `auth/popup-closed-by-user`. */
154
+ cancel(): void {
155
+ const p = this.take();
156
+ if (!p) return;
157
+ p.reject(authError('auth/popup-closed-by-user', 'The popup has been closed by the user before finalizing the operation.'));
158
+ }
159
+
160
+ private take(): Pending | null {
161
+ const p = this.pending;
162
+ this.pending = null;
163
+ this.emit();
164
+ return p;
165
+ }
166
+ }
167
+
168
+ function authError(code: string, message: string): Error & { code: string } {
169
+ const e = new Error(message) as Error & { code: string };
170
+ e.name = 'FirebaseError';
171
+ e.code = code;
172
+ return e;
173
+ }
@@ -0,0 +1,36 @@
1
+ export {
2
+ useAuthFlowHelper,
3
+ type UseAuthFlowHelperResult,
4
+ } from './useAuthFlowHelper.js';
5
+ export {
6
+ useAuthUsers,
7
+ type UseAuthUsersResult,
8
+ } from './useAuthUsers.js';
9
+ export {
10
+ useAuthProviderConfig,
11
+ type AuthProviderConfigEntry,
12
+ type UseAuthProviderConfigResult,
13
+ } from './useAuthProviderConfig.js';
14
+ export {
15
+ useAuthUserEditor,
16
+ type UseAuthUserEditorOptions,
17
+ type UseAuthUserEditorResult,
18
+ } from './useAuthUserEditor.js';
19
+ export {
20
+ authUserEditorReducer,
21
+ initAuthUserEditorState,
22
+ fieldsFromRecord,
23
+ validateAuthUserFields,
24
+ toCreateRequest,
25
+ toUpdateRequest,
26
+ type AuthUserEditorAction,
27
+ type AuthUserEditorErrors,
28
+ type AuthUserEditorFields,
29
+ type AuthUserEditorState,
30
+ } from '../reducers/userEditor.js';
31
+ export {
32
+ AuthFlowController,
33
+ type HelperState,
34
+ type NewIdentitySpec,
35
+ type SandboxIdentity,
36
+ } from '../controller.js';
@@ -0,0 +1,55 @@
1
+ import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react';
2
+ import type { Auth } from 'pyric/auth';
3
+ import {
4
+ AuthFlowController,
5
+ type HelperState,
6
+ type NewIdentitySpec,
7
+ } from '../controller.js';
8
+
9
+ export interface UseAuthFlowHelperResult {
10
+ /** Render snapshot: the in-flight request (or null) + pickable identities. */
11
+ state: HelperState;
12
+ /** Settle the flow with an existing identity (by uid). */
13
+ pick: (uid: string) => void;
14
+ /** Create + sign in as a new identity (seeds it for next time). */
15
+ add: (spec: NewIdentitySpec) => void;
16
+ /** Dismiss — rejects the app's sign-in promise with
17
+ * `auth/popup-closed-by-user` (faithful to `firebase/auth`). */
18
+ cancel: () => void;
19
+ }
20
+
21
+ /**
22
+ * Emulator-style sign-in helper for a sandbox `Auth` handle.
23
+ *
24
+ * Installs an {@link AuthFlowController} as the handle's
25
+ * `AuthFlowResolver` for the lifetime of the calling component — the
26
+ * analog of browser `getAuth` wiring `browserPopupRedirectResolver`.
27
+ * While mounted, any `signInWithPopup` / `signInWithRedirect` call made
28
+ * against `auth` parks on `state.request`; render an account-picker UI
29
+ * (e.g. `<AuthSignInHelper>`) from `state` and settle with
30
+ * `pick` / `add` / `cancel`.
31
+ *
32
+ * Install/uninstall is a paired effect, so the StrictMode double-mount
33
+ * installs and cleanly uninstalls. Sandbox-only: the controller throws
34
+ * `failed-precondition` if `auth` is prod-backed.
35
+ */
36
+ export function useAuthFlowHelper(auth: Auth): UseAuthFlowHelperResult {
37
+ const controller = useMemo(() => new AuthFlowController(auth), [auth]);
38
+
39
+ useEffect(() => {
40
+ controller.install();
41
+ return () => controller.uninstall();
42
+ }, [controller]);
43
+
44
+ const state = useSyncExternalStore(
45
+ useCallback((cb: () => void) => controller.subscribe(cb), [controller]),
46
+ () => controller.snapshot(),
47
+ () => controller.snapshot(),
48
+ );
49
+
50
+ const pick = useCallback((uid: string) => controller.pick(uid), [controller]);
51
+ const add = useCallback((spec: NewIdentitySpec) => controller.add(spec), [controller]);
52
+ const cancel = useCallback(() => controller.cancel(), [controller]);
53
+
54
+ return { state, pick, add, cancel };
55
+ }
@@ -0,0 +1,139 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import type { Auth } from 'pyric/auth';
3
+ import { useAuthApi } from '../authApi.js';
4
+
5
+ /** One provider's current enablement, as the hook exposes it. */
6
+ export interface AuthProviderConfigEntry {
7
+ providerId: string;
8
+ enabled: boolean;
9
+ }
10
+
11
+ export interface UseAuthProviderConfigResult {
12
+ /** Every provider this sandbox has an explicit enablement for. Unknown
13
+ * providers (never toggled) are simply absent — `isEnabled` treats an
14
+ * absent entry as enabled, matching the backend default. */
15
+ config: AuthProviderConfigEntry[];
16
+ isLoading: boolean;
17
+ error: Error | undefined;
18
+ /** Convenience lookup: `true` for a provider that's never been toggled. */
19
+ isEnabled: (providerId: string) => boolean;
20
+ /** Toggle a provider on/off. Sync (in-process) failures throw to the
21
+ * caller, same policy as `useAuthUsers`'s mutation callbacks; an ASYNC
22
+ * (worker-RPC) failure can't reach a sync caller, so it surfaces on the
23
+ * hook's `error` state instead — never an unhandled rejection. */
24
+ setEnabled: (providerId: string, enabled: boolean) => void;
25
+ /** Re-read manually. Rarely needed — every mutation (this hook's own
26
+ * `setEnabled`, another handle, the agent) already triggers the
27
+ * subscription re-list. */
28
+ refresh: () => void;
29
+ }
30
+
31
+ /**
32
+ * Live sign-in provider config view over a sandbox `Auth` handle:
33
+ * `sandbox.getAuthProviderConfig` + `sandbox.subscribeAuthProviderConfig` +
34
+ * `sandbox.setAuthProviderConfig`. Mirrors `useAuthUsers`'s shape exactly
35
+ * (coarse "something changed, re-list" subscription; sync in-process,
36
+ * tolerates a promise over the SharedWorker client).
37
+ *
38
+ * Sandbox-only: throws `failed-precondition` on a prod-backed handle (the
39
+ * hook surfaces that via `error`, same as `useAuthUsers`).
40
+ */
41
+ export function useAuthProviderConfig(auth: Auth): UseAuthProviderConfigResult {
42
+ const {
43
+ getAuthProviderConfig,
44
+ setAuthProviderConfig: apiSetAuthProviderConfig,
45
+ subscribeAuthProviderConfig,
46
+ } = useAuthApi();
47
+
48
+ const [config, setConfig] = useState<AuthProviderConfigEntry[]>([]);
49
+ const [isLoading, setIsLoading] = useState(true);
50
+ const [error, setError] = useState<Error | undefined>(undefined);
51
+
52
+ useEffect(() => {
53
+ let cancelled = false;
54
+ setIsLoading(true);
55
+ setError(undefined);
56
+ const apply = (c: AuthProviderConfigEntry[]) => {
57
+ if (cancelled) return;
58
+ setConfig(c);
59
+ setIsLoading(false);
60
+ };
61
+ const applyErr = (e: unknown) => {
62
+ if (cancelled) return;
63
+ setConfig([]);
64
+ setError(e instanceof Error ? e : new Error(String(e)));
65
+ setIsLoading(false);
66
+ };
67
+ // `getAuthProviderConfig` is SYNC in-process, ASYNC (RPC) over the worker
68
+ // — same thenable-tolerant branch `useAuthUsers` uses for `listUsers`.
69
+ const relist = () => {
70
+ try {
71
+ const r = getAuthProviderConfig(auth) as
72
+ | AuthProviderConfigEntry[]
73
+ | Promise<AuthProviderConfigEntry[]>;
74
+ if (r && typeof (r as Promise<AuthProviderConfigEntry[]>).then === 'function') {
75
+ (r as Promise<AuthProviderConfigEntry[]>).then(apply).catch(applyErr);
76
+ } else {
77
+ apply(r as AuthProviderConfigEntry[]);
78
+ }
79
+ } catch (e) {
80
+ applyErr(e);
81
+ }
82
+ };
83
+ let unsub: (() => void) | undefined;
84
+ try {
85
+ relist();
86
+ unsub = subscribeAuthProviderConfig(auth, relist);
87
+ } catch (e) {
88
+ applyErr(e);
89
+ }
90
+ return () => {
91
+ cancelled = true;
92
+ unsub?.();
93
+ };
94
+ }, [auth, getAuthProviderConfig, subscribeAuthProviderConfig]);
95
+
96
+ const isEnabled = useCallback(
97
+ (providerId: string) => config.find((c) => c.providerId === providerId)?.enabled ?? true,
98
+ [config],
99
+ );
100
+
101
+ const toError = (e: unknown) => (e instanceof Error ? e : new Error(String(e)));
102
+
103
+ const setEnabled = useCallback(
104
+ (providerId: string, enabled: boolean) => {
105
+ // Over the worker this is an RPC promise — a fire-and-forget caller
106
+ // would otherwise leave a rejection unhandled and the toggle silently
107
+ // dead. Route async failures into the hook's error state; sync throws
108
+ // still propagate (in-process policy, matching useAuthUsers).
109
+ const r = apiSetAuthProviderConfig(auth, providerId, enabled) as void | Promise<void>;
110
+ if (r && typeof (r as Promise<void>).then === 'function') {
111
+ (r as Promise<void>).catch((e) => setError(toError(e)));
112
+ }
113
+ },
114
+ [auth, apiSetAuthProviderConfig],
115
+ );
116
+
117
+ const refresh = useCallback(() => {
118
+ try {
119
+ const r = getAuthProviderConfig(auth) as
120
+ | AuthProviderConfigEntry[]
121
+ | Promise<AuthProviderConfigEntry[]>;
122
+ if (r && typeof (r as Promise<AuthProviderConfigEntry[]>).then === 'function') {
123
+ void (r as Promise<AuthProviderConfigEntry[]>)
124
+ .then((c) => {
125
+ setConfig(c);
126
+ setError(undefined);
127
+ })
128
+ .catch((e) => setError(toError(e)));
129
+ } else {
130
+ setConfig(r as AuthProviderConfigEntry[]);
131
+ setError(undefined);
132
+ }
133
+ } catch (e) {
134
+ setError(toError(e));
135
+ }
136
+ }, [auth, getAuthProviderConfig]);
137
+
138
+ return { config, isLoading, error, isEnabled, setEnabled, refresh };
139
+ }
@@ -0,0 +1,76 @@
1
+ import { useCallback, useMemo, useReducer } from 'react';
2
+ import type { AuthUserRecord, CreateUserRequest, UpdateUserRequest } from 'pyric/auth';
3
+ import {
4
+ authUserEditorReducer,
5
+ initAuthUserEditorState,
6
+ isDirty as computeDirty,
7
+ toCreateRequest,
8
+ toUpdateRequest,
9
+ validateAuthUserFields,
10
+ type AuthUserEditorAction,
11
+ type AuthUserEditorErrors,
12
+ type AuthUserEditorFields,
13
+ } from '../reducers/userEditor.js';
14
+
15
+ export interface UseAuthUserEditorOptions {
16
+ /** Existing record to edit. Omit for create mode. */
17
+ initial?: AuthUserRecord;
18
+ }
19
+
20
+ export interface UseAuthUserEditorResult {
21
+ fields: AuthUserEditorFields;
22
+ /** Per-field validation messages (emulator-UI wording). Empty when valid. */
23
+ errors: AuthUserEditorErrors;
24
+ isDirty: boolean;
25
+ isValid: boolean;
26
+ setField: <K extends keyof AuthUserEditorFields>(
27
+ field: K,
28
+ value: AuthUserEditorFields[K],
29
+ ) => void;
30
+ /** Back to the initial snapshot. */
31
+ reset: () => void;
32
+ /** Full payload for `createUser` (every non-empty field). */
33
+ toCreateRequest: () => CreateUserRequest;
34
+ /** Delta payload for `updateUser` (only changed fields). */
35
+ toUpdateRequest: () => UpdateUserRequest;
36
+ /** Raw reducer access for advanced consumers. */
37
+ dispatch: (action: AuthUserEditorAction) => void;
38
+ }
39
+
40
+ /**
41
+ * Headless add/edit-user state machine (reducer-based, like
42
+ * `useDocumentEditor`): field edits, claims-JSON validation with
43
+ * emulator-grade messages, dirtiness vs the initial record, and payload
44
+ * builders for `useAuthUsers`' `createUser` / `updateUser`.
45
+ */
46
+ export function useAuthUserEditor(
47
+ options: UseAuthUserEditorOptions = {},
48
+ ): UseAuthUserEditorResult {
49
+ const [state, dispatch] = useReducer(
50
+ authUserEditorReducer,
51
+ options.initial,
52
+ initAuthUserEditorState,
53
+ );
54
+
55
+ const errors = useMemo(() => validateAuthUserFields(state.fields), [state.fields]);
56
+ const dirty = useMemo(() => computeDirty(state), [state]);
57
+
58
+ const setField = useCallback(
59
+ <K extends keyof AuthUserEditorFields>(field: K, value: AuthUserEditorFields[K]) =>
60
+ dispatch({ type: 'setField', field, value }),
61
+ [],
62
+ );
63
+ const reset = useCallback(() => dispatch({ type: 'reset' }), []);
64
+
65
+ return {
66
+ fields: state.fields,
67
+ errors,
68
+ isDirty: dirty,
69
+ isValid: Object.keys(errors).length === 0,
70
+ setField,
71
+ reset,
72
+ toCreateRequest: () => toCreateRequest(state),
73
+ toUpdateRequest: () => toUpdateRequest(state),
74
+ dispatch,
75
+ };
76
+ }