@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.
- 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,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
|
+
}
|