@pyric/ui 0.1.0-alpha.11 → 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.
- package/package.json +5 -4
- package/src/agents/ContextWindowUsage.tsx +514 -0
- package/src/agents/EmptyState.tsx +27 -0
- package/src/agents/Fold.tsx +64 -0
- package/src/agents/Modal.tsx +65 -0
- package/src/agents/PulsingDot.tsx +28 -0
- package/src/agents/inbrowser-agent-usage.d.ts +141 -0
- package/src/agents/index.ts +28 -0
- package/src/auth/authApi.ts +72 -0
- package/src/auth/claims.ts +63 -0
- package/src/auth/components/AuthProviderToggles.tsx +147 -0
- package/src/auth/components/AuthSignInHelper.tsx +197 -0
- package/src/auth/components/AuthUserForm.tsx +328 -0
- package/src/auth/components/AuthUserList.tsx +219 -0
- package/src/auth/components/ClaimsField.tsx +50 -0
- package/src/auth/components/confirmActions.tsx +114 -0
- package/src/auth/controller.ts +173 -0
- package/src/auth/hooks/index.ts +36 -0
- package/src/auth/hooks/useAuthFlowHelper.ts +55 -0
- package/src/auth/hooks/useAuthProviderConfig.ts +139 -0
- package/src/auth/hooks/useAuthUserEditor.ts +76 -0
- package/src/auth/hooks/useAuthUsers.ts +154 -0
- package/src/auth/index.ts +42 -0
- package/src/auth/providers.ts +28 -0
- package/src/auth/reducers/userEditor.ts +186 -0
- package/src/events/components/ActivityActionItems.tsx +136 -0
- package/src/events/components/ActivityGrid.tsx +197 -0
- package/src/events/components/ActivityGridRow.tsx +65 -0
- package/src/events/components/ProposedChangeDiff.tsx +175 -0
- package/src/events/components/format.ts +21 -0
- package/src/events/components/index.ts +17 -0
- package/src/events/digest.ts +630 -0
- package/src/events/hooks/index.ts +9 -0
- package/src/events/hooks/useActivityDigest.ts +42 -0
- package/src/events/hooks/useActivityStream.ts +86 -0
- package/src/events/index.ts +39 -0
- package/src/events/types.ts +152 -0
- package/src/firestore/components/CollectionList.tsx +78 -0
- package/src/firestore/components/DeleteWithConfirm.tsx +88 -0
- package/src/firestore/components/DocumentEditor.tsx +350 -0
- package/src/firestore/components/DocumentList.tsx +217 -0
- package/src/firestore/components/DocumentPreview.tsx +265 -0
- package/src/firestore/components/FieldRenderer.tsx +25 -0
- package/src/firestore/components/QueryBuilder.tsx +181 -0
- package/src/firestore/components/ReferencePicker.tsx +212 -0
- package/src/firestore/components/TreeEntry.tsx +58 -0
- package/src/firestore/components/context.ts +25 -0
- package/src/firestore/fieldEditors/array.tsx +30 -0
- package/src/firestore/fieldEditors/boolean.tsx +39 -0
- package/src/firestore/fieldEditors/bytes.tsx +53 -0
- package/src/firestore/fieldEditors/geopoint.tsx +71 -0
- package/src/firestore/fieldEditors/map.tsx +33 -0
- package/src/firestore/fieldEditors/null.tsx +27 -0
- package/src/firestore/fieldEditors/number.tsx +43 -0
- package/src/firestore/fieldEditors/reference.tsx +87 -0
- package/src/firestore/fieldEditors/registry.ts +45 -0
- package/src/firestore/fieldEditors/string.tsx +33 -0
- package/src/firestore/fieldEditors/timestamp.tsx +75 -0
- package/src/firestore/fieldEditors/types.ts +68 -0
- package/src/firestore/fieldEditors/vector.tsx +142 -0
- package/src/firestore/firestoreApi.ts +86 -0
- package/src/firestore/hooks/coerceError.ts +34 -0
- package/src/firestore/hooks/index.ts +46 -0
- package/src/firestore/hooks/useCollectionList.ts +102 -0
- package/src/firestore/hooks/useDocumentEditor.ts +161 -0
- package/src/firestore/hooks/useDocumentList.ts +242 -0
- package/src/firestore/hooks/useDocumentSubcollections.ts +86 -0
- package/src/firestore/hooks/useFirestoreCollection.ts +51 -0
- package/src/firestore/hooks/useFirestoreDoc.ts +55 -0
- package/src/firestore/hooks/useQueryBuilder.ts +188 -0
- package/src/firestore/hooks/useRecursiveDelete.ts +77 -0
- package/src/firestore/hooks/useReferencePicker.ts +228 -0
- package/src/firestore/import/parseImport.ts +137 -0
- package/src/firestore/index.ts +87 -0
- package/src/firestore/reducers/defaults.ts +38 -0
- package/src/firestore/reducers/documentEditor.ts +239 -0
- package/src/firestore/reducers/tree.ts +140 -0
- package/src/firestore/reducers/types.ts +92 -0
- package/src/firestore/reducers/validation.ts +129 -0
- package/src/firestore/types.ts +231 -0
- package/src/firestore/validation/ids.ts +45 -0
- package/src/firestore/valueEquality.ts +43 -0
- package/src/index.ts +10 -0
- package/src/primitives/Badge.tsx +40 -0
- package/src/primitives/ConfirmDialog.tsx +137 -0
- package/src/primitives/CopyButton.tsx +62 -0
- package/src/primitives/JsonView.tsx +151 -0
- package/src/primitives/SegmentedControl.tsx +72 -0
- package/src/primitives/Toast.tsx +161 -0
- package/src/primitives/VirtualList.tsx +104 -0
- package/src/primitives/hooks/useContainerSize.ts +53 -0
- package/src/primitives/hooks/useUpdateHighlights.ts +109 -0
- package/src/primitives/index.ts +39 -0
- package/src/primitives/useConfirm.tsx +113 -0
- package/src/rtdb/components/RtdbPathBar.tsx +135 -0
- package/src/rtdb/components/RtdbTree.tsx +409 -0
- package/src/rtdb/editor.ts +79 -0
- package/src/rtdb/hooks/useRtdbTree.ts +137 -0
- package/src/rtdb/index.ts +66 -0
- package/src/rtdb/pathInput.ts +47 -0
- package/src/rtdb/reducers/tree.ts +191 -0
- package/src/rtdb/rtdbApi.ts +23 -0
- package/src/rtdb/values.ts +188 -0
- package/src/rules/components/DenialInspector.tsx +227 -0
- package/src/rules/components/format.ts +171 -0
- package/src/rules/components/index.ts +12 -0
- package/src/rules/components/scope.ts +75 -0
- package/src/rules/hooks/index.ts +5 -0
- package/src/rules/hooks/useDenialTrace.ts +100 -0
- package/src/rules/index.ts +24 -0
- package/src/rules/types.ts +91 -0
- package/src/storage/collisionRename.ts +114 -0
- package/src/storage/components/DeleteSelectionWithConfirm.tsx +193 -0
- package/src/storage/components/ObjectBrowser.tsx +219 -0
- package/src/storage/components/ObjectInspector.tsx +169 -0
- package/src/storage/components/PathBreadcrumb.tsx +84 -0
- package/src/storage/components/UploadDropzone.tsx +182 -0
- package/src/storage/folderPlaceholder.ts +40 -0
- package/src/storage/hooks/index.ts +59 -0
- package/src/storage/hooks/useMetadataEditor.ts +329 -0
- package/src/storage/hooks/useObjectUpload.ts +262 -0
- package/src/storage/hooks/usePathState.ts +94 -0
- package/src/storage/hooks/useStorageDelete.ts +195 -0
- package/src/storage/hooks/useStorageList.ts +261 -0
- package/src/storage/hooks/useStorageObject.ts +162 -0
- package/src/storage/hooks/useStorageRulesGate.ts +270 -0
- package/src/storage/hooks/useStorageSelection.ts +90 -0
- package/src/storage/index.ts +59 -0
- package/src/storage/pendingPrefixes.ts +125 -0
- package/src/storage/previews.tsx +120 -0
- package/src/storage/storageApi.ts +54 -0
- package/src/traffic/components/RuleHeatmap.tsx +97 -0
- package/src/traffic/components/TrafficDetail.tsx +160 -0
- package/src/traffic/components/TrafficGroupRow.tsx +91 -0
- package/src/traffic/components/TrafficLineChart.tsx +139 -0
- package/src/traffic/components/TrafficLog.tsx +175 -0
- package/src/traffic/components/TrafficMetricCards.tsx +77 -0
- package/src/traffic/components/TrafficRow.tsx +69 -0
- package/src/traffic/components/TrafficStats.tsx +73 -0
- package/src/traffic/components/TrafficTimeline.tsx +289 -0
- package/src/traffic/components/format.ts +22 -0
- package/src/traffic/components/index.ts +22 -0
- package/src/traffic/hooks/index.ts +60 -0
- package/src/traffic/hooks/useRuleHeatmap.ts +92 -0
- package/src/traffic/hooks/useTrafficBuckets.ts +146 -0
- package/src/traffic/hooks/useTrafficFilter.ts +74 -0
- package/src/traffic/hooks/useTrafficGroups.ts +126 -0
- package/src/traffic/hooks/useTrafficMetrics.ts +250 -0
- package/src/traffic/hooks/useTrafficMonitor.ts +111 -0
- package/src/traffic/hooks/useTrafficStats.ts +77 -0
- package/src/traffic/index.ts +13 -0
- 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
|
+
}
|