@rebasepro/app 0.13.0 → 0.13.1-canary.g06dbe5b
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/dist/collections/entity-display-cache.d.ts +75 -0
- package/dist/collections/entity-display.d.ts +35 -0
- package/dist/collections/entity_image_preview.d.ts +8 -0
- package/dist/collections/form-layout.d.ts +6 -0
- package/dist/collections/index.d.ts +4 -0
- package/dist/collections/property-path.d.ts +16 -0
- package/dist/collections/property_presentation.d.ts +18 -3
- package/dist/collections/summary-property.d.ts +65 -0
- package/dist/components/LoginView/oauth-redirect-flow.d.ts +86 -0
- package/dist/components/common/useColumnsIds.d.ts +22 -0
- package/dist/core/Rebase.d.ts +1 -1
- package/dist/core/RebaseProps.d.ts +10 -2
- package/dist/hooks/data/collectionQuery.d.ts +45 -0
- package/dist/hooks/data/useRelationSelector.d.ts +12 -1
- package/dist/index.es.js +866 -241
- package/dist/index.es.js.map +1 -1
- package/dist/util/entity_cache.d.ts +19 -3
- package/dist/util/previews.d.ts +19 -0
- package/package.json +7 -7
- package/src/auth/useRebaseAuthController.ts +15 -1
- package/src/collections/entity-display-cache.ts +195 -0
- package/src/collections/entity-display.ts +105 -0
- package/src/collections/entity_image_preview.ts +13 -0
- package/src/collections/form-layout.ts +8 -1
- package/src/collections/index.ts +4 -0
- package/src/collections/property-path.ts +30 -0
- package/src/collections/property_presentation.ts +39 -4
- package/src/collections/summary-property.ts +114 -0
- package/src/collections/title-property.ts +13 -16
- package/src/components/LoginView/LoginView.tsx +48 -26
- package/src/components/LoginView/oauth-redirect-flow.ts +184 -0
- package/src/components/common/useColumnsIds.tsx +67 -29
- package/src/components/common/useDataTableController.tsx +10 -14
- package/src/core/Rebase.tsx +20 -2
- package/src/core/RebaseProps.tsx +10 -2
- package/src/hooks/data/collectionQuery.ts +55 -0
- package/src/hooks/data/useCollection.tsx +29 -21
- package/src/hooks/data/useFetch.tsx +17 -2
- package/src/hooks/data/useRelationSelector.tsx +46 -8
- package/src/hooks/useAuthSubscription.ts +18 -2
- package/src/hooks/useBuildLocalConfigurationPersistence.tsx +20 -12
- package/src/locales/de.ts +2 -0
- package/src/locales/en.ts +3 -0
- package/src/locales/es.ts +2 -0
- package/src/locales/fr.ts +2 -0
- package/src/locales/hi.ts +3 -1
- package/src/locales/it.ts +2 -0
- package/src/locales/pt.ts +2 -0
- package/src/util/entity_cache.ts +34 -31
- package/src/util/previews.ts +49 -29
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { Property } from "@rebasepro/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether a property can stand in for a record in *one line*.
|
|
5
|
+
*
|
|
6
|
+
* Preview surfaces — the reference card, a list row, a board card — have a
|
|
7
|
+
* single line per property and no way to grow. That is a constraint on the
|
|
8
|
+
* *value*, not on the property's importance: a Markdown biography is the most
|
|
9
|
+
* interesting column on an author, and it is still the wrong thing to paste
|
|
10
|
+
* into a 44px card. Ranking it here means the picker can skip it without
|
|
11
|
+
* anyone having to configure their way out of a broken card.
|
|
12
|
+
*
|
|
13
|
+
* Kept beside {@link getTitlePropertyCandidates} because the two answer
|
|
14
|
+
* neighbouring questions — "what is this record called" and "what else can be
|
|
15
|
+
* said about it in a line" — from the same property schema.
|
|
16
|
+
*
|
|
17
|
+
* @group Collections
|
|
18
|
+
*/
|
|
19
|
+
export const SUMMARY_RANK = {
|
|
20
|
+
/**
|
|
21
|
+
* No single-line form exists. A map renders as a key/value table and an
|
|
22
|
+
* array of maps as a stack of them; there is no first line to take.
|
|
23
|
+
*/
|
|
24
|
+
UNUSABLE: 0,
|
|
25
|
+
/**
|
|
26
|
+
* Readable in one line, but only as an excerpt — the property holds a
|
|
27
|
+
* document, and the line is its opening. Worth showing when nothing better
|
|
28
|
+
* is available, never in preference to a value that fits whole.
|
|
29
|
+
*/
|
|
30
|
+
EXCERPT: 1,
|
|
31
|
+
/** Short and self-contained: the line *is* the value. */
|
|
32
|
+
DIRECT: 2
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
/** One of the three summary ranks. */
|
|
36
|
+
export type SummaryRank = typeof SUMMARY_RANK[keyof typeof SUMMARY_RANK];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* File-storage backed content (single image, array of images, generic upload…).
|
|
40
|
+
* Preview surfaces give these their own image slot, so they are never also a
|
|
41
|
+
* text line.
|
|
42
|
+
*
|
|
43
|
+
* @group Collections
|
|
44
|
+
*/
|
|
45
|
+
export function isStorageProperty(property: Property | undefined): boolean {
|
|
46
|
+
if (!property) return false;
|
|
47
|
+
if (property.type === "string" && (property.storage || property.admin?.urlPreview === "image")) return true;
|
|
48
|
+
if (property.type === "array" && property.of && !Array.isArray(property.of)) {
|
|
49
|
+
const inner = property.of;
|
|
50
|
+
if (inner.type === "string" && (inner.storage || inner.admin?.urlPreview === "image")) return true;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* True when the property holds free text long enough to be a document rather
|
|
57
|
+
* than a value — Markdown, or an explicitly multi-line string.
|
|
58
|
+
*
|
|
59
|
+
* @group Collections
|
|
60
|
+
*/
|
|
61
|
+
export function isLongTextProperty(property: Property | undefined): boolean {
|
|
62
|
+
if (!property || property.type !== "string") return false;
|
|
63
|
+
return Boolean(property.admin?.markdown || property.admin?.multiline);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* How well a property reads as one line of a preview. See {@link SUMMARY_RANK}.
|
|
68
|
+
*
|
|
69
|
+
* Decided from the property *schema* only: no values are consulted, so the
|
|
70
|
+
* answer is stable for a collection and can be memoised per collection rather
|
|
71
|
+
* than per row.
|
|
72
|
+
*
|
|
73
|
+
* @group Collections
|
|
74
|
+
*/
|
|
75
|
+
export function rankSummaryProperty(property: Property | undefined): SummaryRank {
|
|
76
|
+
if (!property) return SUMMARY_RANK.UNUSABLE;
|
|
77
|
+
|
|
78
|
+
// Rendered by the image slot, not as text.
|
|
79
|
+
if (isStorageProperty(property)) return SUMMARY_RANK.UNUSABLE;
|
|
80
|
+
|
|
81
|
+
// A map is a table of its own, whether it declares its properties (a row
|
|
82
|
+
// per sub-property) or not (a key/value dump). Neither has a first line.
|
|
83
|
+
if (property.type === "map") return SUMMARY_RANK.UNUSABLE;
|
|
84
|
+
|
|
85
|
+
if (property.type === "array") {
|
|
86
|
+
// A tuple: several properties side by side, so there is nothing single
|
|
87
|
+
// about the line it would produce.
|
|
88
|
+
if (Array.isArray(property.of)) return SUMMARY_RANK.UNUSABLE;
|
|
89
|
+
if (!property.of) return property.oneOf ? SUMMARY_RANK.UNUSABLE : SUMMARY_RANK.DIRECT;
|
|
90
|
+
// Arrays of block-shaped things inherit the block shape.
|
|
91
|
+
const innerRank = rankSummaryProperty(property.of);
|
|
92
|
+
if (innerRank === SUMMARY_RANK.UNUSABLE) return SUMMARY_RANK.UNUSABLE;
|
|
93
|
+
// An array of documents is a stack of excerpts — worse than one
|
|
94
|
+
// excerpt, and never what a card should lead with.
|
|
95
|
+
if (innerRank === SUMMARY_RANK.EXCERPT) return SUMMARY_RANK.UNUSABLE;
|
|
96
|
+
// Short values render as a run of chips, which fits a line.
|
|
97
|
+
return SUMMARY_RANK.DIRECT;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (isLongTextProperty(property)) return SUMMARY_RANK.EXCERPT;
|
|
101
|
+
|
|
102
|
+
// Numbers, dates, booleans, enums, plain strings, and single references or
|
|
103
|
+
// relations (which collapse to one linked line) all fit as they are.
|
|
104
|
+
return SUMMARY_RANK.DIRECT;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* True when the property has any single-line form at all.
|
|
109
|
+
*
|
|
110
|
+
* @group Collections
|
|
111
|
+
*/
|
|
112
|
+
export function canSummariseProperty(property: Property | undefined): boolean {
|
|
113
|
+
return rankSummaryProperty(property) !== SUMMARY_RANK.UNUSABLE;
|
|
114
|
+
}
|
|
@@ -2,6 +2,9 @@ import { isRelationalCollectionConfig, type Property, type Relation } from "@reb
|
|
|
2
2
|
import { generateForeignKeyName } from "@rebasepro/utils";
|
|
3
3
|
import type { AdminCollection } from "@rebasepro/admin-types";
|
|
4
4
|
import { getPrimaryKeys, isPropertyBuilder } from "@rebasepro/common";
|
|
5
|
+
import { getPropertyInPath } from "./property-path";
|
|
6
|
+
import { getDisplayPropertyKey, hasDeclaredDisplay } from "./entity-display";
|
|
7
|
+
import { isStorageProperty } from "./summary-property";
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* How good a property is as the human-readable title of an entity.
|
|
@@ -56,19 +59,6 @@ function isHidden(property: Property): boolean {
|
|
|
56
59
|
return Boolean(property.admin?.hideFromCollection);
|
|
57
60
|
}
|
|
58
61
|
|
|
59
|
-
/**
|
|
60
|
-
* File-storage backed content (single image, array of images, generic upload…).
|
|
61
|
-
* Rendered by the dedicated image slot, so never a title.
|
|
62
|
-
*/
|
|
63
|
-
function isStorageProperty(property: Property): boolean {
|
|
64
|
-
if (property.type === "string" && (property.storage || property.admin?.urlPreview === "image")) return true;
|
|
65
|
-
if (property.type === "array" && property.of && !Array.isArray(property.of)) {
|
|
66
|
-
const inner = property.of;
|
|
67
|
-
if (inner.type === "string" && (inner.storage || inner.admin?.urlPreview === "image")) return true;
|
|
68
|
-
}
|
|
69
|
-
return false;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
62
|
/**
|
|
73
63
|
* Every column on this collection that stores a foreign key: the `localKey` of
|
|
74
64
|
* each owning relation (declared inline on a property or in `relations[]`), the
|
|
@@ -182,8 +172,13 @@ export function getTitlePropertyCandidates<M extends Record<string, unknown>>(
|
|
|
182
172
|
): string[] {
|
|
183
173
|
if (!collection.properties) return [];
|
|
184
174
|
|
|
185
|
-
|
|
186
|
-
|
|
175
|
+
// `getPropertyInPath`, not `properties[key]`: a declared title on a nested
|
|
176
|
+
// field — `profile.display_name` — was looked up flat, found nothing, and
|
|
177
|
+
// silently fell through to the derived ranking. The dotted form has been
|
|
178
|
+
// documented as valid the whole time.
|
|
179
|
+
const declared = getDisplayPropertyKey(collection, "title");
|
|
180
|
+
if (declared && getPropertyInPath(collection.properties, declared)) {
|
|
181
|
+
return [declared];
|
|
187
182
|
}
|
|
188
183
|
|
|
189
184
|
const idKeys = new Set<string>(getPrimaryKeys(collection) as string[]);
|
|
@@ -237,7 +232,9 @@ export function getLeadingRelationTitleKey<M extends Record<string, unknown>>(
|
|
|
237
232
|
collection: AdminCollection<M>
|
|
238
233
|
): string | undefined {
|
|
239
234
|
if (!collection.properties) return undefined;
|
|
240
|
-
|
|
235
|
+
// The collection states what it is called, in one form or the other; either
|
|
236
|
+
// way it is not asking for the leading-relation heuristic.
|
|
237
|
+
if (hasDeclaredDisplay(collection, "title")) return undefined;
|
|
241
238
|
|
|
242
239
|
const idKeys = new Set<string>(getPrimaryKeys(collection) as string[]);
|
|
243
240
|
const foreignKeys = getForeignKeyColumns(collection);
|
|
@@ -41,6 +41,7 @@ import { ErrorView } from "../ErrorView";
|
|
|
41
41
|
import { RebaseLogo } from "../RebaseLogo";
|
|
42
42
|
import { LanguageToggle } from "../LanguageToggle";
|
|
43
43
|
import { useModeController, useTranslation } from "../../hooks";
|
|
44
|
+
import { consumeOAuthCallback, startOAuthRedirect } from "./oauth-redirect-flow";
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
* Props for the generic LoginView.
|
|
@@ -274,25 +275,38 @@ export function LoginView({
|
|
|
274
275
|
return () => clearTimeout(timer);
|
|
275
276
|
}, []);
|
|
276
277
|
|
|
277
|
-
// Effect to handle incoming redirect OAuth codes (GitHub, LinkedIn, etc.)
|
|
278
|
+
// Effect to handle incoming redirect OAuth codes (GitHub, LinkedIn, etc.).
|
|
279
|
+
//
|
|
280
|
+
// The `code` is only spent when it belongs to an authorization this browser
|
|
281
|
+
// started: `consumeOAuthCallback` matches the returned `state` against the
|
|
282
|
+
// one generated at button-click time. Without that, an attacker who starts
|
|
283
|
+
// their own authorization, captures the code and gets the victim to load
|
|
284
|
+
// `?code=<theirs>` hands the victim a session on the *attacker's* account.
|
|
278
285
|
useEffect(() => {
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
286
|
+
const result = consumeOAuthCallback(window.location.search);
|
|
287
|
+
if (result.status === "none") return;
|
|
288
|
+
|
|
289
|
+
// Clear URL search params without page reload
|
|
290
|
+
const cleanUrl = window.location.origin + window.location.pathname;
|
|
291
|
+
window.history.replaceState({}, document.title, cleanUrl);
|
|
292
|
+
|
|
293
|
+
if (result.status === "error") {
|
|
294
|
+
console.error(`OAuth sign-in failed: ${result.error}`);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (result.status === "mismatch") {
|
|
298
|
+
console.error("Ignoring an OAuth code that does not match a sign-in this browser started.");
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (authController.oauthLogin) {
|
|
303
|
+
authController.oauthLogin(result.provider, {
|
|
304
|
+
code: result.code,
|
|
305
|
+
redirectUri: result.redirectUri,
|
|
306
|
+
...(result.codeVerifier ? { codeVerifier: result.codeVerifier } : {})
|
|
307
|
+
}).catch((err: unknown) => {
|
|
308
|
+
console.error(`${result.provider} login failed:`, err);
|
|
309
|
+
});
|
|
296
310
|
}
|
|
297
311
|
}, [authController]);
|
|
298
312
|
|
|
@@ -612,10 +626,15 @@ function GitHubLoginButton({
|
|
|
612
626
|
githubClientId: string
|
|
613
627
|
}) {
|
|
614
628
|
const handleClick = () => {
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
629
|
+
// GitHub OAuth Apps do not implement PKCE, so `state` is the whole
|
|
630
|
+
// binding between this click and the code that comes back.
|
|
631
|
+
void startOAuthRedirect({
|
|
632
|
+
provider: "github",
|
|
633
|
+
authorizeUrl: "https://github.com/login/oauth/authorize",
|
|
634
|
+
clientId: githubClientId,
|
|
635
|
+
redirectUri: window.location.origin + window.location.pathname,
|
|
636
|
+
scope: "read:user,user:email"
|
|
637
|
+
});
|
|
619
638
|
};
|
|
620
639
|
|
|
621
640
|
return (
|
|
@@ -642,10 +661,13 @@ function LinkedInLoginButton({
|
|
|
642
661
|
linkedinClientId: string
|
|
643
662
|
}) {
|
|
644
663
|
const handleClick = () => {
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
664
|
+
void startOAuthRedirect({
|
|
665
|
+
provider: "linkedin",
|
|
666
|
+
authorizeUrl: "https://www.linkedin.com/oauth/v2/authorization",
|
|
667
|
+
clientId: linkedinClientId,
|
|
668
|
+
redirectUri: window.location.origin + window.location.pathname,
|
|
669
|
+
scope: "openid profile email"
|
|
670
|
+
});
|
|
649
671
|
};
|
|
650
672
|
|
|
651
673
|
return (
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser half of the OAuth authorization-code flow.
|
|
3
|
+
*
|
|
4
|
+
* The server only ever sees a `code` and a `redirectUri` taken from the
|
|
5
|
+
* request body, so `state`, PKCE and the binding between "this browser started
|
|
6
|
+
* a login" and "this code came back" are, by construction, the client's job.
|
|
7
|
+
* In the admin login they had been nobody's job: the authorize URL carried no
|
|
8
|
+
* `state`, and the return leg accepted any `?code=` as long as a localStorage
|
|
9
|
+
* marker naming the *provider* was set — a marker written on button click and
|
|
10
|
+
* removed only on a callback that carried a code, so abandoning the flow left
|
|
11
|
+
* it set indefinitely.
|
|
12
|
+
*
|
|
13
|
+
* Everything here is provider-agnostic on purpose. A button that wants a new
|
|
14
|
+
* provider calls {@link startOAuthRedirect} and gets state, expiry and PKCE
|
|
15
|
+
* without deciding anything, which is what stops the next provider shipping
|
|
16
|
+
* without them.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** sessionStorage, not localStorage: a login attempt belongs to one tab. */
|
|
20
|
+
const STORAGE_KEY = "rebase_oauth_redirect";
|
|
21
|
+
|
|
22
|
+
/** How long a started authorization stays valid. */
|
|
23
|
+
const PENDING_TTL_MS = 10 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
export interface OAuthRedirectRequest {
|
|
26
|
+
/** Provider id, as mounted at `POST /auth/<id>`. */
|
|
27
|
+
provider: string;
|
|
28
|
+
/** The provider's authorization endpoint. */
|
|
29
|
+
authorizeUrl: string;
|
|
30
|
+
clientId: string;
|
|
31
|
+
scope: string;
|
|
32
|
+
/** Where the provider sends the browser back. Echoed to the token exchange. */
|
|
33
|
+
redirectUri: string;
|
|
34
|
+
/** Set when the provider implements PKCE. */
|
|
35
|
+
pkce?: boolean;
|
|
36
|
+
/** Extra authorize-endpoint parameters (`response_type`, and so on). */
|
|
37
|
+
params?: Record<string, string>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What the browser remembers between leaving for the provider and coming back. */
|
|
41
|
+
export interface PendingOAuthRedirect {
|
|
42
|
+
provider: string;
|
|
43
|
+
state: string;
|
|
44
|
+
codeVerifier?: string;
|
|
45
|
+
redirectUri: string;
|
|
46
|
+
startedAt: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type OAuthCallbackResult =
|
|
50
|
+
/** No OAuth callback in this URL. */
|
|
51
|
+
| { status: "none" }
|
|
52
|
+
/** The provider reported a failure, or the user declined. */
|
|
53
|
+
| { status: "error"; error: string }
|
|
54
|
+
/**
|
|
55
|
+
* A code arrived that this browser cannot account for: no pending
|
|
56
|
+
* authorization, an expired one, or a `state` that does not match. This is
|
|
57
|
+
* what a login-CSRF / code-injection attempt looks like.
|
|
58
|
+
*/
|
|
59
|
+
| { status: "mismatch" }
|
|
60
|
+
| { status: "ok"; provider: string; code: string; codeVerifier?: string; redirectUri: string };
|
|
61
|
+
|
|
62
|
+
type Storage = Pick<globalThis.Storage, "getItem" | "setItem" | "removeItem">;
|
|
63
|
+
|
|
64
|
+
function defaultStorage(): Storage | null {
|
|
65
|
+
try {
|
|
66
|
+
return window.sessionStorage;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function base64Url(bytes: Uint8Array): string {
|
|
73
|
+
let binary = "";
|
|
74
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
75
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function randomToken(byteLength = 32): string {
|
|
79
|
+
const bytes = new Uint8Array(byteLength);
|
|
80
|
+
crypto.getRandomValues(bytes);
|
|
81
|
+
return base64Url(bytes);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function pkceChallenge(verifier: string): Promise<string> {
|
|
85
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
86
|
+
return base64Url(new Uint8Array(digest));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function readPendingOAuthRedirect(storage: Storage | null = defaultStorage()): PendingOAuthRedirect | null {
|
|
90
|
+
if (!storage) return null;
|
|
91
|
+
const raw = storage.getItem(STORAGE_KEY);
|
|
92
|
+
if (!raw) return null;
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(raw) as PendingOAuthRedirect;
|
|
95
|
+
if (!parsed?.provider || !parsed?.state) return null;
|
|
96
|
+
return parsed;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function clearPendingOAuthRedirect(storage: Storage | null = defaultStorage()): void {
|
|
103
|
+
storage?.removeItem(STORAGE_KEY);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Build the authorize URL and the pending record that will validate its
|
|
108
|
+
* callback. Split out from {@link startOAuthRedirect} so it can be tested
|
|
109
|
+
* without navigating.
|
|
110
|
+
*/
|
|
111
|
+
export async function buildOAuthAuthorization(
|
|
112
|
+
request: OAuthRedirectRequest,
|
|
113
|
+
now: number = Date.now()
|
|
114
|
+
): Promise<{ url: string; pending: PendingOAuthRedirect }> {
|
|
115
|
+
const state = randomToken();
|
|
116
|
+
const url = new URL(request.authorizeUrl);
|
|
117
|
+
url.searchParams.set("response_type", "code");
|
|
118
|
+
url.searchParams.set("client_id", request.clientId);
|
|
119
|
+
url.searchParams.set("redirect_uri", request.redirectUri);
|
|
120
|
+
url.searchParams.set("scope", request.scope);
|
|
121
|
+
url.searchParams.set("state", state);
|
|
122
|
+
for (const [key, value] of Object.entries(request.params ?? {})) {
|
|
123
|
+
url.searchParams.set(key, value);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let codeVerifier: string | undefined;
|
|
127
|
+
if (request.pkce) {
|
|
128
|
+
codeVerifier = randomToken(32);
|
|
129
|
+
url.searchParams.set("code_challenge", await pkceChallenge(codeVerifier));
|
|
130
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
url: url.toString(),
|
|
135
|
+
pending: { provider: request.provider, state, codeVerifier, redirectUri: request.redirectUri, startedAt: now }
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Remember the authorization and send the browser to the provider. */
|
|
140
|
+
export async function startOAuthRedirect(
|
|
141
|
+
request: OAuthRedirectRequest,
|
|
142
|
+
storage: Storage | null = defaultStorage()
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
const { url, pending } = await buildOAuthAuthorization(request);
|
|
145
|
+
storage?.setItem(STORAGE_KEY, JSON.stringify(pending));
|
|
146
|
+
window.location.href = url;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Interpret a return from the provider.
|
|
151
|
+
*
|
|
152
|
+
* Clears the pending record on **every** path that saw a callback — including
|
|
153
|
+
* `?error=` and a state mismatch — so a declined or abandoned consent screen
|
|
154
|
+
* cannot leave the browser primed to accept somebody else's code later.
|
|
155
|
+
*/
|
|
156
|
+
export function consumeOAuthCallback(
|
|
157
|
+
search: string,
|
|
158
|
+
storage: Storage | null = defaultStorage(),
|
|
159
|
+
now: number = Date.now()
|
|
160
|
+
): OAuthCallbackResult {
|
|
161
|
+
const params = new URLSearchParams(search);
|
|
162
|
+
const code = params.get("code");
|
|
163
|
+
const state = params.get("state");
|
|
164
|
+
const error = params.get("error");
|
|
165
|
+
|
|
166
|
+
if (!code && !error && !state) return { status: "none" };
|
|
167
|
+
|
|
168
|
+
const pending = readPendingOAuthRedirect(storage);
|
|
169
|
+
clearPendingOAuthRedirect(storage);
|
|
170
|
+
|
|
171
|
+
if (error) return { status: "error", error };
|
|
172
|
+
if (!code) return { status: "mismatch" };
|
|
173
|
+
if (!pending) return { status: "mismatch" };
|
|
174
|
+
if (now - pending.startedAt > PENDING_TTL_MS) return { status: "mismatch" };
|
|
175
|
+
if (!state || state !== pending.state) return { status: "mismatch" };
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
status: "ok",
|
|
179
|
+
provider: pending.provider,
|
|
180
|
+
code,
|
|
181
|
+
codeVerifier: pending.codeVerifier,
|
|
182
|
+
redirectUri: pending.redirectUri
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -1,28 +1,10 @@
|
|
|
1
|
-
import type { Property, MapProperty, Properties } from "@rebasepro/types";
|
|
1
|
+
import type { Property, MapProperty, Properties, RelationProperty } from "@rebasepro/types";
|
|
2
2
|
import { useMemo } from "react";
|
|
3
3
|
;
|
|
4
|
-
import { getSubcollections } from "@rebasepro/common";
|
|
4
|
+
import { getChildViewDeclaringProperties, getChildViewRelationPropertyKeys, getSubcollections } from "@rebasepro/common";
|
|
5
5
|
import type { AdminCollection } from "@rebasepro/admin-types";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
* Get a property in a property tree from a dot-path like `address.street`.
|
|
9
|
-
* Inlined here to avoid importing property-aware utilities from the admin layer.
|
|
10
|
-
*/
|
|
11
|
-
function getPropertyInPath(properties: Properties, path: string): Property | undefined {
|
|
12
|
-
if (typeof properties === "object") {
|
|
13
|
-
if (path in properties) {
|
|
14
|
-
return (properties as Record<string, Property>)[path];
|
|
15
|
-
}
|
|
16
|
-
if (path.includes(".")) {
|
|
17
|
-
const pathSegments = path.split(".");
|
|
18
|
-
const childProperty = (properties as Record<string, Property>)[pathSegments[0]];
|
|
19
|
-
if (typeof childProperty === "object" && childProperty.type === "map" && childProperty.properties) {
|
|
20
|
-
return getPropertyInPath(childProperty.properties, pathSegments.slice(1).join("."));
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
return undefined;
|
|
25
|
-
}
|
|
6
|
+
import { getPropertyInPath } from "../../collections/property-path";
|
|
7
|
+
import { isDisabled } from "../../collections/property_presentation";
|
|
26
8
|
|
|
27
9
|
export type PropertyColumnConfig = {
|
|
28
10
|
key: string;
|
|
@@ -32,6 +14,43 @@ export type PropertyColumnConfig = {
|
|
|
32
14
|
export function getSubcollectionColumnId(collection: AdminCollection<any>) {
|
|
33
15
|
return `subcollection:${collection.slug}`;
|
|
34
16
|
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The jump-to-tab columns a collection table does not need, because the relation
|
|
20
|
+
* behind them already has a column of its own.
|
|
21
|
+
*
|
|
22
|
+
* Every child view gets a 200px button column that opens its tab. For a relation
|
|
23
|
+
* declared in `relations` that button is the relation's only presence in the
|
|
24
|
+
* table. For one declared as a property it is the second: the property's own
|
|
25
|
+
* column is already there, hydrated by the list fetch's `include: ["*"]`, showing
|
|
26
|
+
* the child rows themselves — and carrying the *same heading*, because the tab
|
|
27
|
+
* takes its name from the declaring property. Two columns called "Applications",
|
|
28
|
+
* one of them a button, and nothing in the header to tell them apart.
|
|
29
|
+
*
|
|
30
|
+
* The property column wins: it shows what the children are, and each chip in it
|
|
31
|
+
* opens one. The tab stays reachable by opening the record, which is what the
|
|
32
|
+
* rest of the table's rows do anyway.
|
|
33
|
+
*
|
|
34
|
+
* Unless the author hid that column — `hideFromCollection` on the property, or a
|
|
35
|
+
* `propertiesOrder` that omits it, is a statement about the column and not about
|
|
36
|
+
* the relation, so the button comes back rather than the relation dropping out
|
|
37
|
+
* of the table altogether.
|
|
38
|
+
*/
|
|
39
|
+
export function getRedundantChildViewColumnIds<M extends Record<string, any>>(collection: AdminCollection<M>): Set<string> {
|
|
40
|
+
const redundant = new Set<string>();
|
|
41
|
+
const properties = collection.properties ?? {};
|
|
42
|
+
const order = collection.propertiesOrder as string[] | undefined;
|
|
43
|
+
|
|
44
|
+
for (const [viewKey, propertyKey] of getChildViewDeclaringProperties(collection)) {
|
|
45
|
+
const property = properties[propertyKey] as Property | undefined;
|
|
46
|
+
if (!property || property.admin?.hideFromCollection) continue;
|
|
47
|
+
if (order && !order.includes(propertyKey)) continue;
|
|
48
|
+
redundant.add(`subcollection:${viewKey}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return redundant;
|
|
52
|
+
}
|
|
53
|
+
|
|
35
54
|
export function useColumnIds<M extends Record<string, any>>(collection: AdminCollection<M>, includeSubcollections: boolean): PropertyColumnConfig[] {
|
|
36
55
|
return useMemo(() => {
|
|
37
56
|
if (collection.propertiesOrder) {
|
|
@@ -59,9 +78,14 @@ function hideAndExpandKeys<M extends Record<string, any>>(collection: AdminColle
|
|
|
59
78
|
// Track processed keys to avoid duplicates
|
|
60
79
|
const processedPropertyKeys = new Set<string>();
|
|
61
80
|
|
|
81
|
+
// Both column paths funnel through here, including a `propertiesOrder` that
|
|
82
|
+
// was saved back with the button column in it.
|
|
83
|
+
const redundantChildViewColumns = getRedundantChildViewColumnIds(collection);
|
|
84
|
+
|
|
62
85
|
const result = keys.flatMap((key) => {
|
|
63
86
|
// Skip if already processed (handles duplicates in propertiesOrder)
|
|
64
87
|
if (processedPropertyKeys.has(key)) return [null];
|
|
88
|
+
if (redundantChildViewColumns.has(key)) return [null];
|
|
65
89
|
|
|
66
90
|
// Check if it's a top-level property
|
|
67
91
|
const property = collection.properties[key];
|
|
@@ -85,7 +109,7 @@ function hideAndExpandKeys<M extends Record<string, any>>(collection: AdminColle
|
|
|
85
109
|
}
|
|
86
110
|
return [{
|
|
87
111
|
key,
|
|
88
|
-
disabled:
|
|
112
|
+
disabled: isDisabled(property) || Boolean(property.admin?.readOnly)
|
|
89
113
|
}];
|
|
90
114
|
}
|
|
91
115
|
|
|
@@ -108,8 +132,8 @@ function hideAndExpandKeys<M extends Record<string, any>>(collection: AdminColle
|
|
|
108
132
|
|
|
109
133
|
return [{
|
|
110
134
|
key,
|
|
111
|
-
disabled:
|
|
112
|
-
|
|
135
|
+
disabled: isDisabled(rootProperty) || Boolean(rootProperty.admin?.readOnly) ||
|
|
136
|
+
isDisabled(nestedProperty) || Boolean(nestedProperty.admin?.readOnly)
|
|
113
137
|
}];
|
|
114
138
|
}
|
|
115
139
|
}
|
|
@@ -163,7 +187,7 @@ function hideAndExpandKeys<M extends Record<string, any>>(collection: AdminColle
|
|
|
163
187
|
} else {
|
|
164
188
|
result.push({
|
|
165
189
|
key: propKey,
|
|
166
|
-
disabled:
|
|
190
|
+
disabled: isDisabled(property) || Boolean(property.admin?.readOnly)
|
|
167
191
|
});
|
|
168
192
|
processedPropertyKeys.add(propKey);
|
|
169
193
|
}
|
|
@@ -194,22 +218,36 @@ function getDefaultColumnKeys<M extends Record<string, any> = any>(collection: A
|
|
|
194
218
|
}
|
|
195
219
|
|
|
196
220
|
export function getColumnKeysForProperty(property: Property, key: string, disabled?: boolean): PropertyColumnConfig[] {
|
|
221
|
+
// A key with no property behind it describes no column. Callers walk whole
|
|
222
|
+
// property maps here to build a table's columns, so reading `.type` off a
|
|
223
|
+
// hole threw away every other column with it.
|
|
224
|
+
if (!property) return [];
|
|
197
225
|
if (property.type === "map" && property.admin?.spreadChildren && property.properties) {
|
|
198
226
|
return Object.entries(property.properties)
|
|
199
227
|
.flatMap(([childKey, childProperty]) => getColumnKeysForProperty(
|
|
200
228
|
childProperty as Readonly<Property>,
|
|
201
229
|
`${key}.${childKey}`,
|
|
202
|
-
disabled ||
|
|
230
|
+
disabled || isDisabled(property) || Boolean(property.admin?.readOnly))
|
|
203
231
|
);
|
|
204
232
|
}
|
|
205
233
|
return [{
|
|
206
234
|
key,
|
|
207
|
-
disabled: disabled ||
|
|
235
|
+
disabled: disabled || isDisabled(property) || Boolean(property.admin?.readOnly)
|
|
208
236
|
}];
|
|
209
237
|
}
|
|
210
238
|
|
|
211
239
|
export function getFormFieldKeys(collection: AdminCollection): string[] {
|
|
212
|
-
const
|
|
240
|
+
const properties = collection.properties ?? {};
|
|
241
|
+
// A many-relation the entity view already lists as a tab is not an input:
|
|
242
|
+
// the tab is the treatment, and rendering the same declaration again as a
|
|
243
|
+
// picker asked the author to select a collection's own children from a
|
|
244
|
+
// dropdown. `renderInForm` is how a project that wants the picker asks for
|
|
245
|
+
// it back — opt in, because the tab is the default.
|
|
246
|
+
const consumedByChildView = getChildViewRelationPropertyKeys(collection);
|
|
247
|
+
const propertyKeys = Object.keys(properties).filter(key => {
|
|
248
|
+
if (!consumedByChildView.has(key)) return true;
|
|
249
|
+
return (properties[key] as RelationProperty).admin?.renderInForm === true;
|
|
250
|
+
});
|
|
213
251
|
const additionalFields = collection.additionalFields ?? [];
|
|
214
252
|
const allKeys = [
|
|
215
253
|
...propertyKeys,
|
|
@@ -5,6 +5,7 @@ import { useLocation } from "react-router";
|
|
|
5
5
|
import { useData, useRebaseContext } from "../../hooks";
|
|
6
6
|
import { useDataOrder } from "../../hooks/data/useDataOrder";
|
|
7
7
|
import { populateFetchCache } from "../../hooks/data/useFetch";
|
|
8
|
+
import { toFindParams } from "../../hooks/data/collectionQuery";
|
|
8
9
|
import { getRelationIncludeParams } from "../../util/previews";
|
|
9
10
|
import { Entity, EntityReference, EntityRelation, FilterValues, User, WhereFilterOp, FindResponse } from "@rebasepro/types";
|
|
10
11
|
import { EntityTableController, RebaseContext, SelectedCellProps, AdminCollection } from "@rebasepro/admin-types";
|
|
@@ -260,21 +261,16 @@ export function useDataTableController<M extends Record<string, any> = any, USER
|
|
|
260
261
|
const includeParams = getRelationIncludeParams(collection);
|
|
261
262
|
|
|
262
263
|
if (accessor.listen) {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
orderBy: orderByParams,
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}, (res) => onEntitiesUpdate(res.data as Entity<M>[]), onError);
|
|
264
|
+
// Assembled in one place — see `toFindParams`. Listing the fields
|
|
265
|
+
// here is what let `searchExplain` reach three of four read paths.
|
|
266
|
+
unsubscribe = accessor.listen(
|
|
267
|
+
toFindParams({ where: whereParams, limit: itemCount, orderBy: orderByParams,
|
|
268
|
+
searchString, include: includeParams }),
|
|
269
|
+
(res) => onEntitiesUpdate(res.data as Entity<M>[]), onError);
|
|
270
270
|
} else {
|
|
271
|
-
accessor.find(
|
|
272
|
-
where: whereParams,
|
|
273
|
-
|
|
274
|
-
orderBy: orderByParams,
|
|
275
|
-
searchString,
|
|
276
|
-
include: includeParams
|
|
277
|
-
})
|
|
271
|
+
accessor.find(
|
|
272
|
+
toFindParams({ where: whereParams, limit: itemCount, orderBy: orderByParams,
|
|
273
|
+
searchString, include: includeParams }))
|
|
278
274
|
.then((res) => onEntitiesUpdate(res.data as Entity<M>[]))
|
|
279
275
|
.catch(onError);
|
|
280
276
|
unsubscribe = () => undefined;
|