@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,154 @@
1
+ import { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import type {
3
+ Auth,
4
+ AuthUserRecord,
5
+ CreateUserRequest,
6
+ UpdateUserRequest,
7
+ } from 'pyric/auth';
8
+ import { useAuthApi } from '../authApi.js';
9
+
10
+ export interface UseAuthUsersResult {
11
+ /** Users matching {@link filter} (everyone when the filter is empty). */
12
+ users: AuthUserRecord[];
13
+ /** Unfiltered count: lets a list distinguish "no users at all" from
14
+ * "no results for this filter". */
15
+ totalCount: number;
16
+ isLoading: boolean;
17
+ error: Error | undefined;
18
+ /** Case-insensitive substring match over uid, email, display name and
19
+ * phone number (the emulator UI's search semantics). */
20
+ filter: string;
21
+ setFilter: (filter: string) => void;
22
+ createUser: (request: CreateUserRequest) => AuthUserRecord;
23
+ updateUser: (uid: string, update: UpdateUserRequest) => AuthUserRecord;
24
+ deleteUser: (uid: string) => void;
25
+ clearUsers: () => void;
26
+ /** Re-list manually. Rarely needed, every mutation (including ones made
27
+ * by the agent or the running app) already triggers `subscribeUsers`. */
28
+ refresh: () => void;
29
+ }
30
+
31
+ function matches(user: AuthUserRecord, needle: string): boolean {
32
+ return [user.uid, user.email, user.displayName, user.phoneNumber].some(
33
+ (v) => v != null && v.toLowerCase().includes(needle),
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Live user-admin view over a sandbox `Auth` handle:
39
+ * `sandbox.listUsers` + `sandbox.subscribeUsers` + CRUD actions.
40
+ *
41
+ * The subscription is coarse ("something changed"): any user-DB
42
+ * mutation (from these actions, the running app's sign-ups, the
43
+ * agent's seeding) triggers a re-list, so the view stays live without
44
+ * per-row bookkeeping. Filtering is client-side (the sandbox is
45
+ * in-process; there is no server to push the query to).
46
+ *
47
+ * Mutation errors (e.g. `auth/uid-already-exists`) throw to the caller:
48
+ * handle them at the call site like the firestore hooks' `createDocument`.
49
+ * Sandbox-only: throws `failed-precondition` on a prod-backed handle (the
50
+ * hook surfaces that via `error`).
51
+ */
52
+ export function useAuthUsers(auth: Auth): UseAuthUsersResult {
53
+ // The sandbox auth ops, injected: in-process `pyric/auth` by default, or the
54
+ // SharedWorker client bundle when a consumer (Pyric Studio served mode) wraps
55
+ // the tree in an `AuthApiProvider`.
56
+ const {
57
+ listUsers,
58
+ subscribeUsers,
59
+ createUser: apiCreateUser,
60
+ updateUser: apiUpdateUser,
61
+ deleteUser: apiDeleteUser,
62
+ clearUsers: apiClearUsers,
63
+ } = useAuthApi();
64
+ const [all, setAll] = useState<AuthUserRecord[]>([]);
65
+ const [isLoading, setIsLoading] = useState(true);
66
+ const [error, setError] = useState<Error | undefined>(undefined);
67
+ const [filter, setFilter] = useState('');
68
+
69
+ useEffect(() => {
70
+ let cancelled = false;
71
+ setIsLoading(true);
72
+ setError(undefined);
73
+ const applyUsers = (u: AuthUserRecord[]) => {
74
+ if (cancelled) return;
75
+ setAll(u);
76
+ setIsLoading(false);
77
+ };
78
+ const applyErr = (e: unknown) => {
79
+ if (cancelled) return;
80
+ setAll([]);
81
+ setError(e instanceof Error ? e : new Error(String(e)));
82
+ setIsLoading(false);
83
+ };
84
+ // `listUsers` is SYNC in-process (apply immediately, preserving the sync
85
+ // contract existing consumers + tests rely on) but ASYNC over the worker (an
86
+ // RPC), so branch on a thenable. The subscription is coarse: re-list on any
87
+ // change.
88
+ const relist = () => {
89
+ try {
90
+ const r = listUsers(auth) as AuthUserRecord[] | Promise<AuthUserRecord[]>;
91
+ if (r && typeof (r as Promise<AuthUserRecord[]>).then === 'function') {
92
+ (r as Promise<AuthUserRecord[]>).then(applyUsers).catch(applyErr);
93
+ } else {
94
+ applyUsers(r as AuthUserRecord[]);
95
+ }
96
+ } catch (e) {
97
+ applyErr(e);
98
+ }
99
+ };
100
+ let unsub: (() => void) | undefined;
101
+ try {
102
+ relist();
103
+ unsub = subscribeUsers(auth, relist);
104
+ } catch (e) {
105
+ applyErr(e);
106
+ }
107
+ return () => {
108
+ cancelled = true;
109
+ unsub?.();
110
+ };
111
+ }, [auth, listUsers, subscribeUsers]);
112
+
113
+ const users = useMemo(() => {
114
+ const needle = filter.trim().toLowerCase();
115
+ if (!needle) return all;
116
+ return all.filter((u) => matches(u, needle));
117
+ }, [all, filter]);
118
+
119
+ const createUser = useCallback(
120
+ (request: CreateUserRequest) => apiCreateUser(auth, request),
121
+ [auth, apiCreateUser],
122
+ );
123
+ const updateUser = useCallback(
124
+ (uid: string, update: UpdateUserRequest) => apiUpdateUser(auth, uid, update),
125
+ [auth, apiUpdateUser],
126
+ );
127
+ const deleteUser = useCallback(
128
+ (uid: string) => apiDeleteUser(auth, uid),
129
+ [auth, apiDeleteUser],
130
+ );
131
+ const clearUsers = useCallback(() => apiClearUsers(auth), [auth, apiClearUsers]);
132
+ const refresh = useCallback(() => {
133
+ const r = listUsers(auth) as AuthUserRecord[] | Promise<AuthUserRecord[]>;
134
+ if (r && typeof (r as Promise<AuthUserRecord[]>).then === 'function') {
135
+ void (r as Promise<AuthUserRecord[]>).then((u) => setAll(u)).catch(() => {});
136
+ } else {
137
+ setAll(r as AuthUserRecord[]);
138
+ }
139
+ }, [auth, listUsers]);
140
+
141
+ return {
142
+ users,
143
+ totalCount: all.length,
144
+ isLoading,
145
+ error,
146
+ filter,
147
+ setFilter,
148
+ createUser,
149
+ updateUser,
150
+ deleteUser,
151
+ clearUsers,
152
+ refresh,
153
+ };
154
+ }
@@ -0,0 +1,42 @@
1
+ export * from './hooks/index.js';
2
+
3
+ // Injectable auth API bundle (Pyric Studio data-backend swap): defaults to
4
+ // in-process `pyric/auth`; a consumer can provide the SharedWorker client.
5
+ export { AuthApiProvider, useAuthApi, type AuthApi } from './authApi.js';
6
+ export {
7
+ AuthSignInHelper,
8
+ type AuthSignInHelperProps,
9
+ } from './components/AuthSignInHelper.js';
10
+ export {
11
+ AuthUserList,
12
+ type AuthUserListProps,
13
+ } from './components/AuthUserList.js';
14
+ export {
15
+ AuthProviderToggles,
16
+ DEFAULT_KNOWN_PROVIDER_IDS,
17
+ type AuthProviderTogglesProps,
18
+ } from './components/AuthProviderToggles.js';
19
+ export {
20
+ AuthUserForm,
21
+ type AuthUserFormField,
22
+ type AuthUserFormFieldName,
23
+ type AuthUserFormProps,
24
+ type AuthUserFormSubmit,
25
+ } from './components/AuthUserForm.js';
26
+ export {
27
+ ClaimsField,
28
+ type ClaimsFieldProps,
29
+ } from './components/ClaimsField.js';
30
+ export {
31
+ DeleteUserWithConfirm,
32
+ type DeleteUserWithConfirmProps,
33
+ ClearUsersWithConfirm,
34
+ type ClearUsersWithConfirmProps,
35
+ } from './components/confirmActions.js';
36
+ export {
37
+ validateSerializedClaims,
38
+ FORBIDDEN_CUSTOM_CLAIMS,
39
+ CUSTOM_CLAIMS_MAX_LENGTH,
40
+ type ClaimsValidationResult,
41
+ } from './claims.js';
42
+ export { PROVIDER_LABELS, providerLabel } from './providers.js';
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Provider-id → human label mapping, mirroring the provider set the
3
+ * Firebase emulator UI recognizes (it maps the same ids to icons; a
4
+ * headless library maps them to text and leaves icons to the consumer
5
+ * via `data-pyric-provider-id`).
6
+ */
7
+ export const PROVIDER_LABELS: Record<string, string> = {
8
+ 'google.com': 'Google',
9
+ 'apple.com': 'Apple',
10
+ 'gc.apple.com': 'Game Center',
11
+ 'facebook.com': 'Facebook',
12
+ 'github.com': 'GitHub',
13
+ 'microsoft.com': 'Microsoft',
14
+ 'playgames.google.com': 'Play Games',
15
+ 'twitter.com': 'Twitter',
16
+ 'yahoo.com': 'Yahoo',
17
+ password: 'Email/Password',
18
+ phone: 'Phone',
19
+ anonymous: 'Anonymous',
20
+ oidc: 'OIDC',
21
+ saml: 'SAML',
22
+ };
23
+
24
+ /** Label for a provider id; falls back to the raw id for custom
25
+ * `OAuthProvider` ids the map doesn't know. */
26
+ export function providerLabel(providerId: string): string {
27
+ return PROVIDER_LABELS[providerId] ?? providerId;
28
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Reducer behind `useAuthUserEditor` — pure, React-free (drivable from
3
+ * tests or non-React hosts, like the firestore `documentEditor` reducer).
4
+ *
5
+ * Validation messages match the Firebase emulator UI:
6
+ * - email pattern → "Invalid email"
7
+ * - password length → "Password should be at least 6 characters"
8
+ * - password without email → "Email is required for password authentication"
9
+ * - claims → `validateSerializedClaims` messages
10
+ */
11
+ import type { AuthUserRecord, CreateUserRequest, UpdateUserRequest } from 'pyric/auth';
12
+ import { validateSerializedClaims } from '../claims.js';
13
+
14
+ /** Editable field set. `claimsText` is the raw textarea JSON.
15
+ * `providerIds` are the linked FEDERATED providers (`google.com`,
16
+ * `apple.com`, …) — `password` is credential-derived (the password
17
+ * field) and never appears here. */
18
+ export interface AuthUserEditorFields {
19
+ email: string;
20
+ password: string;
21
+ displayName: string;
22
+ phoneNumber: string;
23
+ photoUrl: string;
24
+ emailVerified: boolean;
25
+ disabled: boolean;
26
+ claimsText: string;
27
+ providerIds: string[];
28
+ }
29
+
30
+ export interface AuthUserEditorState {
31
+ fields: AuthUserEditorFields;
32
+ /** What {@link reset} returns to; dirtiness is measured against this. */
33
+ initial: AuthUserEditorFields;
34
+ }
35
+
36
+ export type AuthUserEditorAction =
37
+ | {
38
+ type: 'setField';
39
+ field: keyof AuthUserEditorFields;
40
+ value: AuthUserEditorFields[keyof AuthUserEditorFields];
41
+ }
42
+ | { type: 'reset' };
43
+
44
+ export interface AuthUserEditorErrors {
45
+ email?: string;
46
+ password?: string;
47
+ claims?: string;
48
+ }
49
+
50
+ const EMPTY_FIELDS: AuthUserEditorFields = {
51
+ email: '',
52
+ password: '',
53
+ displayName: '',
54
+ phoneNumber: '',
55
+ photoUrl: '',
56
+ emailVerified: false,
57
+ disabled: false,
58
+ claimsText: '',
59
+ providerIds: [],
60
+ };
61
+
62
+ /** Credential-derived / token-level ids — sign-in methods, not linkable
63
+ * federated entries (mirrors the sandbox backend's rule). */
64
+ const NON_FEDERATED_IDS = new Set(['password', 'anonymous', 'phone']);
65
+
66
+ /** Same permissive shape the emulator UI uses (`pattern` validation). */
67
+ const EMAIL_REGEX = /^[^@]+@[^@]+\.[^@]+$/;
68
+ const PASSWORD_MIN_LENGTH = 6;
69
+
70
+ export function fieldsFromRecord(record?: AuthUserRecord): AuthUserEditorFields {
71
+ if (!record) return { ...EMPTY_FIELDS };
72
+ return {
73
+ email: record.email ?? '',
74
+ password: '',
75
+ displayName: record.displayName ?? '',
76
+ phoneNumber: record.phoneNumber ?? '',
77
+ photoUrl: record.photoUrl ?? '',
78
+ emailVerified: record.emailVerified,
79
+ disabled: record.disabled,
80
+ claimsText: Object.keys(record.customClaims).length
81
+ ? JSON.stringify(record.customClaims, null, 2)
82
+ : '',
83
+ providerIds: record.providerUserInfo
84
+ .map((p) => p.providerId)
85
+ .filter((id) => !NON_FEDERATED_IDS.has(id)),
86
+ };
87
+ }
88
+
89
+ export function initAuthUserEditorState(initial?: AuthUserRecord): AuthUserEditorState {
90
+ const fields = fieldsFromRecord(initial);
91
+ return { fields, initial: { ...fields } };
92
+ }
93
+
94
+ export function authUserEditorReducer(
95
+ state: AuthUserEditorState,
96
+ action: AuthUserEditorAction,
97
+ ): AuthUserEditorState {
98
+ switch (action.type) {
99
+ case 'setField':
100
+ return { ...state, fields: { ...state.fields, [action.field]: action.value } };
101
+ case 'reset':
102
+ return { ...state, fields: { ...state.initial } };
103
+ }
104
+ }
105
+
106
+ export function validateAuthUserFields(fields: AuthUserEditorFields): AuthUserEditorErrors {
107
+ const errors: AuthUserEditorErrors = {};
108
+ const email = fields.email.trim();
109
+ const password = fields.password;
110
+ if (email && !EMAIL_REGEX.test(email)) {
111
+ errors.email = 'Invalid email';
112
+ }
113
+ if (password && password.length < PASSWORD_MIN_LENGTH) {
114
+ errors.password = `Password should be at least ${PASSWORD_MIN_LENGTH} characters`;
115
+ }
116
+ if (password && !email) {
117
+ errors.password = 'Email is required for password authentication';
118
+ }
119
+ const claims = validateSerializedClaims(fields.claimsText);
120
+ if (!claims.ok) errors.claims = claims.message;
121
+ return errors;
122
+ }
123
+
124
+ export function isDirty(state: AuthUserEditorState): boolean {
125
+ const { fields, initial } = state;
126
+ return (Object.keys(fields) as Array<keyof AuthUserEditorFields>).some((k) =>
127
+ k === 'providerIds'
128
+ ? !sameIds(fields.providerIds, initial.providerIds)
129
+ : fields[k] !== initial[k],
130
+ );
131
+ }
132
+
133
+ /** Order-insensitive id-list equality (toggling A then B ≡ B then A). */
134
+ function sameIds(a: readonly string[], b: readonly string[]): boolean {
135
+ if (a.length !== b.length) return false;
136
+ const sorted = [...b].sort();
137
+ return [...a].sort().every((id, i) => id === sorted[i]);
138
+ }
139
+
140
+ function parsedClaims(fields: AuthUserEditorFields): Record<string, unknown> | undefined {
141
+ const r = validateSerializedClaims(fields.claimsText);
142
+ return r.ok ? r.claims : undefined;
143
+ }
144
+
145
+ /** Full payload for `sandbox.createUser` — every non-empty field. */
146
+ export function toCreateRequest(state: AuthUserEditorState): CreateUserRequest {
147
+ const f = state.fields;
148
+ const req: CreateUserRequest = {
149
+ emailVerified: f.emailVerified,
150
+ disabled: f.disabled,
151
+ };
152
+ if (f.email.trim()) req.email = f.email.trim();
153
+ if (f.password) req.password = f.password;
154
+ if (f.displayName.trim()) req.displayName = f.displayName.trim();
155
+ if (f.phoneNumber.trim()) req.phoneNumber = f.phoneNumber.trim();
156
+ if (f.photoUrl.trim()) req.photoUrl = f.photoUrl.trim();
157
+ const claims = parsedClaims(f);
158
+ if (claims) req.customClaims = claims;
159
+ if (f.providerIds.length) {
160
+ req.providerUserInfo = f.providerIds.map((providerId) => ({ providerId }));
161
+ }
162
+ return req;
163
+ }
164
+
165
+ /** Delta payload for `sandbox.updateUser` — only fields that changed
166
+ * from the initial record. A cleared displayName maps to `null`
167
+ * (the update API's clear semantics). */
168
+ export function toUpdateRequest(state: AuthUserEditorState): UpdateUserRequest {
169
+ const { fields: f, initial: i } = state;
170
+ const req: UpdateUserRequest = {};
171
+ if (f.email.trim() !== i.email && f.email.trim()) req.email = f.email.trim();
172
+ if (f.password && f.password !== i.password) req.password = f.password;
173
+ if (f.displayName.trim() !== i.displayName) {
174
+ req.displayName = f.displayName.trim() || null;
175
+ }
176
+ if (f.emailVerified !== i.emailVerified) req.emailVerified = f.emailVerified;
177
+ if (f.disabled !== i.disabled) req.disabled = f.disabled;
178
+ if (f.claimsText !== i.claimsText) {
179
+ req.customClaims = parsedClaims(f) ?? {};
180
+ }
181
+ if (!sameIds(f.providerIds, i.providerIds)) {
182
+ // Replacement semantics; the backend re-derives the password entry.
183
+ req.providerUserInfo = f.providerIds.map((providerId) => ({ providerId }));
184
+ }
185
+ return req;
186
+ }
@@ -0,0 +1,136 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { ActivityDigest, ActivityRow } from '../digest.js';
3
+
4
+ /** A single action item — a mechanical fact that invites a decision. */
5
+ export interface ActivityActionItem {
6
+ /** Stable key. */
7
+ key: string;
8
+ /** What it is — drives `data-pyric-action-type`. Today: `denied`. */
9
+ type: 'denied';
10
+ /** The headline (mechanical: "4 writes to /notes were denied"). */
11
+ title: string;
12
+ /** Sub-line — attribution / cause. */
13
+ meta?: string;
14
+ /** The rows this item summarizes — for drill-in / linking into the
15
+ * matching Activity band (items LINK to bands, they don't duplicate). */
16
+ rows: ActivityRow[];
17
+ }
18
+
19
+ export interface ActivityActionItemsProps {
20
+ digest: ActivityDigest;
21
+ /** Render the action button/affordance for an item (e.g. a "Debug"
22
+ * link). Returns `null` to render no action. */
23
+ renderAction?: (item: ActivityActionItem) => ReactNode;
24
+ /** Override item-title composition (host owns app-semantic copy). */
25
+ renderTitle?: (item: ActivityActionItem) => ReactNode;
26
+ className?: string;
27
+ /** Rendered when there are no action items — default renders nothing
28
+ * (calm by default; the region collapses). */
29
+ emptyState?: ReactNode;
30
+ }
31
+
32
+ /**
33
+ * Group denial rows by collection prefix so the headline aggregates
34
+ * ("4 writes to /notes were denied") rather than listing each row.
35
+ */
36
+ function buildDenialItems(digest: ActivityDigest): ActivityActionItem[] {
37
+ if (digest.denials.length === 0) return [];
38
+ const byPrefix = new Map<string, ActivityRow[]>();
39
+ for (const r of digest.denials) {
40
+ // Collection prefix = everything up to the last segment.
41
+ const i = r.target.lastIndexOf('/');
42
+ const prefix = i === -1 ? r.target : r.target.slice(0, i);
43
+ const key = prefix ? `/${prefix.replace(/^\/+/, '')}` : '(root)';
44
+ const list = byPrefix.get(key);
45
+ if (list) list.push(r);
46
+ else byPrefix.set(key, [r]);
47
+ }
48
+ const items: ActivityActionItem[] = [];
49
+ for (const [prefix, rows] of byPrefix) {
50
+ const subjects = new Set(
51
+ rows.map((r) => r.subjectUid).filter((u): u is string => !!u),
52
+ );
53
+ const n = rows.length;
54
+ const noun = n === 1 ? 'write' : 'writes';
55
+ const title = `${n} ${noun} to ${prefix} ${n === 1 ? 'was' : 'were'} denied`;
56
+ const meta =
57
+ subjects.size === 1
58
+ ? `All by ${[...subjects][0]}.`
59
+ : subjects.size > 1
60
+ ? `By ${subjects.size} users.`
61
+ : undefined;
62
+ const item: ActivityActionItem = {
63
+ key: `denied:${prefix}`,
64
+ type: 'denied',
65
+ title,
66
+ rows,
67
+ };
68
+ if (meta) item.meta = meta;
69
+ items.push(item);
70
+ }
71
+ // Most denials first.
72
+ items.sort((a, b) => b.rows.length - a.rows.length);
73
+ return items;
74
+ }
75
+
76
+ /**
77
+ * The action-items tier — the few things wanting a decision, surfaced
78
+ * ABOVE the activity grid (design-ideation Tier 2 / "Needs you").
79
+ * Denials lead and are first-class. Mechanical copy by default; the host
80
+ * supplies the action affordance (e.g. a "Debug" link into the rules
81
+ * debugger) via `renderAction`.
82
+ *
83
+ * Data contract:
84
+ * - `[data-pyric-ui="activity-action-items"]` — the root (absent when
85
+ * empty and no `emptyState`).
86
+ * - `[data-pyric-action-item]` (+ `data-pyric-action-type`,
87
+ * `data-pyric-action-count`) — one item.
88
+ * - `[data-pyric-action-title]` / `[data-pyric-action-meta]` — the copy.
89
+ * - `[data-pyric-action-affordance]` — wraps the host's action node.
90
+ */
91
+ export function ActivityActionItems({
92
+ digest,
93
+ renderAction,
94
+ renderTitle,
95
+ className,
96
+ emptyState,
97
+ }: ActivityActionItemsProps): ReactNode {
98
+ const items = buildDenialItems(digest);
99
+
100
+ if (items.length === 0) {
101
+ return emptyState ? (
102
+ <div
103
+ className={className}
104
+ data-pyric-ui="activity-action-items"
105
+ data-pyric-empty=""
106
+ >
107
+ {emptyState}
108
+ </div>
109
+ ) : null;
110
+ }
111
+
112
+ return (
113
+ <div className={className} data-pyric-ui="activity-action-items">
114
+ {items.map((item) => (
115
+ <div
116
+ key={item.key}
117
+ data-pyric-action-item=""
118
+ data-pyric-action-type={item.type}
119
+ data-pyric-action-count={item.rows.length}
120
+ >
121
+ <div data-pyric-action-what="">
122
+ <div data-pyric-action-title="">
123
+ {renderTitle ? renderTitle(item) : item.title}
124
+ </div>
125
+ {item.meta ? (
126
+ <div data-pyric-action-meta="">{item.meta}</div>
127
+ ) : null}
128
+ </div>
129
+ {renderAction ? (
130
+ <div data-pyric-action-affordance="">{renderAction(item)}</div>
131
+ ) : null}
132
+ </div>
133
+ ))}
134
+ </div>
135
+ );
136
+ }