@rebasepro/app 0.14.1 → 0.14.2-canary.g27a129e
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/core/RebaseProps.d.ts +6 -1
- package/dist/index.es.js +116 -8
- package/dist/index.es.js.map +1 -1
- package/package.json +7 -7
- package/src/components/Debug/UIReferenceView.tsx +40 -1
- package/src/components/LoginView/LoginView.tsx +73 -2
- package/src/core/Rebase.tsx +27 -4
- package/src/core/RebaseProps.tsx +7 -1
- package/src/hooks/useResolvedComponent.tsx +5 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/app",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.14.
|
|
4
|
+
"version": "0.14.2-canary.g27a129e",
|
|
5
5
|
"description": "Rebase core — framework-agnostic runtime for data-driven admin panels",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/rebaseco"
|
|
@@ -52,12 +52,12 @@
|
|
|
52
52
|
"magic-string": "^0.30.0",
|
|
53
53
|
"notistack": "^3.0.2",
|
|
54
54
|
"react-i18next": "^17.0.11",
|
|
55
|
-
"@rebasepro/admin-types": "0.14.
|
|
56
|
-
"@rebasepro/
|
|
57
|
-
"@rebasepro/forms": "0.14.
|
|
58
|
-
"@rebasepro/
|
|
59
|
-
"@rebasepro/
|
|
60
|
-
"@rebasepro/
|
|
55
|
+
"@rebasepro/admin-types": "0.14.2-canary.g27a129e",
|
|
56
|
+
"@rebasepro/common": "0.14.2-canary.g27a129e",
|
|
57
|
+
"@rebasepro/forms": "0.14.2-canary.g27a129e",
|
|
58
|
+
"@rebasepro/ui": "0.14.2-canary.g27a129e",
|
|
59
|
+
"@rebasepro/types": "0.14.2-canary.g27a129e",
|
|
60
|
+
"@rebasepro/utils": "0.14.2-canary.g27a129e"
|
|
61
61
|
},
|
|
62
62
|
"peerDependencies": {
|
|
63
63
|
"react": ">=19.2.7",
|
|
@@ -599,7 +599,7 @@ selected: true }, { name: "Tags" }].map(c => (
|
|
|
599
599
|
All variants from <code className="font-mono text-xs">Typography</code>. Colors: primary (default), secondary, disabled, error.
|
|
600
600
|
</Typography>
|
|
601
601
|
<div className="flex flex-col gap-3">
|
|
602
|
-
{(["h1", "h2", "h3", "h4", "h5", "h6", "subtitle1", "subtitle2", "body1", "body2", "caption", "label", "button"] as const).map(v => (
|
|
602
|
+
{(["h1", "h2", "h3", "h4", "h5", "h6", "lead", "subtitle1", "subtitle2", "body1", "body2", "caption", "label", "button"] as const).map(v => (
|
|
603
603
|
<div key={v} className={cls("flex items-baseline gap-4 border-b pb-3 last:border-0", defaultBorderMixin)}>
|
|
604
604
|
<span className="w-24 shrink-0 text-xs text-surface-400 font-mono">{v}</span>
|
|
605
605
|
{/* `component="p"` keeps the variant's styling but not its
|
|
@@ -618,6 +618,45 @@ selected: true }, { name: "Tags" }].map(c => (
|
|
|
618
618
|
<Typography key={c} color={c}>color="{c}"</Typography>
|
|
619
619
|
))}
|
|
620
620
|
</div>
|
|
621
|
+
|
|
622
|
+
{/* The data tiers are shown with real content rather than the
|
|
623
|
+
pangram above: `micro` is always one or two words naming the
|
|
624
|
+
value beneath it, and `mono` is always a measurement. A
|
|
625
|
+
specimen reading "The quick brown fox" would demonstrate the
|
|
626
|
+
size and misrepresent the purpose. */}
|
|
627
|
+
<div className={cls("mt-8 pt-6 border-t", defaultBorderMixin)}>
|
|
628
|
+
<Typography variant="subtitle2" className="block mb-1">Data tiers</Typography>
|
|
629
|
+
<Typography variant="body2" color="secondary" className="block mb-5 max-w-[65ch]">
|
|
630
|
+
For values that are looked up rather than read: a field name, a measurement,
|
|
631
|
+
a headline figure. <code className="font-mono text-xs">mono</code> and{" "}
|
|
632
|
+
<code className="font-mono text-xs">stat</code> both carry{" "}
|
|
633
|
+
<code className="font-mono text-xs">tabular-nums</code>, so a column of them
|
|
634
|
+
keeps its decimal points aligned and a live counter does not jitter as its
|
|
635
|
+
digits change width.
|
|
636
|
+
</Typography>
|
|
637
|
+
|
|
638
|
+
<div className="flex flex-wrap items-start gap-x-12 gap-y-5">
|
|
639
|
+
{([
|
|
640
|
+
["Region", "europe-west1"],
|
|
641
|
+
["Took", "32.2s"],
|
|
642
|
+
["Last run", "16/08/2026, 05:30:32"]
|
|
643
|
+
] as const).map(([label, value]) => (
|
|
644
|
+
<div key={label}>
|
|
645
|
+
<Typography variant="micro" component="div" color="secondary" className="block">
|
|
646
|
+
{label}
|
|
647
|
+
</Typography>
|
|
648
|
+
<Typography variant="mono" component="div" className="block mt-1 text-sm">
|
|
649
|
+
{value}
|
|
650
|
+
</Typography>
|
|
651
|
+
</div>
|
|
652
|
+
))}
|
|
653
|
+
</div>
|
|
654
|
+
|
|
655
|
+
<div className="mt-6">
|
|
656
|
+
<span className="text-xs text-surface-400 font-mono">stat</span>
|
|
657
|
+
<Typography variant="stat" component="div" className="block mt-1">1,284</Typography>
|
|
658
|
+
</div>
|
|
659
|
+
</div>
|
|
621
660
|
</SectionBlock>
|
|
622
661
|
|
|
623
662
|
{/* ═══════════════════════════════════════════════
|
|
@@ -199,6 +199,22 @@ function warnAboutHalfConfiguredOAuth(
|
|
|
199
199
|
);
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Whether a provider's error code means "the visitor backed out" rather than
|
|
204
|
+
* "the sign-in failed". Closing the popup is an answer, not a fault, and
|
|
205
|
+
* showing it in red reads as a broken login.
|
|
206
|
+
*/
|
|
207
|
+
function isUserCancellation(code: string | undefined): boolean {
|
|
208
|
+
if (!code) return false;
|
|
209
|
+
return [
|
|
210
|
+
"access_denied",
|
|
211
|
+
"popup_closed",
|
|
212
|
+
"popup_closed_by_user",
|
|
213
|
+
"user_cancel",
|
|
214
|
+
"immediate_failed"
|
|
215
|
+
].includes(code);
|
|
216
|
+
}
|
|
217
|
+
|
|
202
218
|
/**
|
|
203
219
|
* Generic login view component that works with any AuthControllerExtended.
|
|
204
220
|
* Feature-detects capabilities to show/hide login methods.
|
|
@@ -232,6 +248,12 @@ export function LoginView({
|
|
|
232
248
|
const [mode, setMode] = useState<AuthMode>("buttons");
|
|
233
249
|
const [fadeIn, setFadeIn] = useState(false);
|
|
234
250
|
const [viewVisible, setViewVisible] = useState(true);
|
|
251
|
+
// Failures that never reach the auth controller: a provider that answers the
|
|
252
|
+
// popup with an error, a redirect whose `state` does not match, a Google
|
|
253
|
+
// script that never loaded. The controller records everything it is *given*
|
|
254
|
+
// in `authProviderError`, so it cannot speak for the half of the flow that
|
|
255
|
+
// happens before the code reaches it.
|
|
256
|
+
const [providerError, setProviderError] = useState<string | null>(null);
|
|
235
257
|
// Lives here, not in LoginForm: the box is ticked on the provider screen
|
|
236
258
|
// and read after a sign-in that happens one or two screens later, so it has
|
|
237
259
|
// to outlive both. The form also unmounts on every login↔register switch.
|
|
@@ -259,6 +281,9 @@ export function LoginView({
|
|
|
259
281
|
}, [newsletterOptIn, onNewsletterOptIn]);
|
|
260
282
|
|
|
261
283
|
const switchMode = (newMode: AuthMode) => {
|
|
284
|
+
// A message about the last Google attempt has nothing to say about the
|
|
285
|
+
// email form the visitor just opened.
|
|
286
|
+
setProviderError(null);
|
|
262
287
|
setViewVisible(false);
|
|
263
288
|
setTimeout(() => {
|
|
264
289
|
setMode(newMode);
|
|
@@ -321,14 +346,21 @@ export function LoginView({
|
|
|
321
346
|
|
|
322
347
|
if (result.status === "error") {
|
|
323
348
|
console.error(`OAuth sign-in failed: ${result.error}`);
|
|
349
|
+
if (!isUserCancellation(result.error)) {
|
|
350
|
+
setProviderError(`Sign-in failed: ${result.error}`);
|
|
351
|
+
}
|
|
324
352
|
return;
|
|
325
353
|
}
|
|
326
354
|
if (result.status === "mismatch") {
|
|
327
355
|
console.error("Ignoring an OAuth code that does not match a sign-in this browser started.");
|
|
356
|
+
setProviderError("This sign-in could not be verified as one you started in this browser. Please try again.");
|
|
328
357
|
return;
|
|
329
358
|
}
|
|
330
359
|
|
|
331
360
|
if (authController.oauthLogin) {
|
|
361
|
+
// The rejection is recorded in `authProviderError`, which the screen
|
|
362
|
+
// renders — the catch is here so a shown error does not also surface
|
|
363
|
+
// as an unhandled rejection.
|
|
332
364
|
authController.oauthLogin(result.provider, {
|
|
333
365
|
code: result.code,
|
|
334
366
|
redirectUri: result.redirectUri,
|
|
@@ -354,6 +386,16 @@ export function LoginView({
|
|
|
354
386
|
logoComponent = <RebaseLogo/>;
|
|
355
387
|
}
|
|
356
388
|
|
|
389
|
+
// The controller's own record of the last rejected sign-in, ignored once a
|
|
390
|
+
// user is present — a stale failure must not sit over a screen that has
|
|
391
|
+
// since succeeded. `LoginForm` renders the same value for the email path.
|
|
392
|
+
const controllerError = authController.authProviderError && !authController.user
|
|
393
|
+
? (authController.authProviderError instanceof Error
|
|
394
|
+
? authController.authProviderError.message
|
|
395
|
+
: String(authController.authProviderError))
|
|
396
|
+
: null;
|
|
397
|
+
const buttonsErrorMessage = providerError ?? controllerError;
|
|
398
|
+
|
|
357
399
|
let notAllowedMessage: string | undefined;
|
|
358
400
|
if (notAllowedError) {
|
|
359
401
|
if (typeof notAllowedError === "string") {
|
|
@@ -430,6 +472,15 @@ export function LoginView({
|
|
|
430
472
|
{/* Provider buttons screen */}
|
|
431
473
|
{mode === "buttons" && (
|
|
432
474
|
<div className="w-full flex flex-col gap-3 mt-2">
|
|
475
|
+
{/* Every provider button fails here, and this
|
|
476
|
+
screen used to render nothing for it: a
|
|
477
|
+
refused Google sign-in closed the popup
|
|
478
|
+
and returned the visitor to an unchanged
|
|
479
|
+
login screen, with the reason only in the
|
|
480
|
+
browser console. */}
|
|
481
|
+
{buttonsErrorMessage && (
|
|
482
|
+
<ErrorView error={buttonsErrorMessage}/>
|
|
483
|
+
)}
|
|
433
484
|
{(title || subtitle) && (
|
|
434
485
|
<div className="text-center mb-2">
|
|
435
486
|
{title && (
|
|
@@ -497,6 +548,7 @@ export function LoginView({
|
|
|
497
548
|
googleClientId={googleClientId}
|
|
498
549
|
authController={authController}
|
|
499
550
|
onSignedIn={() => subscribeIfOptedIn(authControllerRef.current.user?.email)}
|
|
551
|
+
onError={setProviderError}
|
|
500
552
|
/>
|
|
501
553
|
)}
|
|
502
554
|
{hasGitHubLogin && githubClientId && (
|
|
@@ -621,13 +673,20 @@ function GoogleLoginButton({
|
|
|
621
673
|
disabled,
|
|
622
674
|
googleClientId,
|
|
623
675
|
authController,
|
|
624
|
-
onSignedIn
|
|
676
|
+
onSignedIn,
|
|
677
|
+
onError
|
|
625
678
|
}: {
|
|
626
679
|
disabled?: boolean,
|
|
627
680
|
googleClientId: string,
|
|
628
681
|
authController: AuthControllerExtended,
|
|
629
682
|
/** Called once the controller has accepted the provider's code. */
|
|
630
|
-
onSignedIn?: () => void
|
|
683
|
+
onSignedIn?: () => void,
|
|
684
|
+
/**
|
|
685
|
+
* Reports a failure the auth controller never saw — Google answering the
|
|
686
|
+
* popup with an error, or its script never having loaded. A rejected
|
|
687
|
+
* `googleLogin` is not reported here: the controller already records it.
|
|
688
|
+
*/
|
|
689
|
+
onError?: (message: string | null) => void
|
|
631
690
|
}) {
|
|
632
691
|
const codeClientRef = useRef<{ requestCode(): void } | null>(null);
|
|
633
692
|
// The code client is built once and its callback closes over this render's
|
|
@@ -635,6 +694,8 @@ function GoogleLoginButton({
|
|
|
635
694
|
// rather than the one that existed when Google's script happened to load.
|
|
636
695
|
const onSignedInRef = useRef(onSignedIn);
|
|
637
696
|
onSignedInRef.current = onSignedIn;
|
|
697
|
+
const onErrorRef = useRef(onError);
|
|
698
|
+
onErrorRef.current = onError;
|
|
638
699
|
|
|
639
700
|
useEffect(() => {
|
|
640
701
|
if (!authController.googleLogin) return;
|
|
@@ -649,8 +710,16 @@ function GoogleLoginButton({
|
|
|
649
710
|
callback: async (response: { code?: string; error?: string }) => {
|
|
650
711
|
if (response.error || !response.code) {
|
|
651
712
|
console.error("Google login error:", response.error);
|
|
713
|
+
if (!isUserCancellation(response.error)) {
|
|
714
|
+
onErrorRef.current?.(
|
|
715
|
+
response.error
|
|
716
|
+
? `Google sign-in failed: ${response.error}`
|
|
717
|
+
: "Google sign-in failed. Please try again."
|
|
718
|
+
);
|
|
719
|
+
}
|
|
652
720
|
return;
|
|
653
721
|
}
|
|
722
|
+
onErrorRef.current?.(null);
|
|
654
723
|
try {
|
|
655
724
|
// Send the authorization code to the backend.
|
|
656
725
|
// redirectUri "postmessage" is required when using popup ux_mode.
|
|
@@ -672,8 +741,10 @@ function GoogleLoginButton({
|
|
|
672
741
|
const handleClick = () => {
|
|
673
742
|
if (!codeClientRef.current) {
|
|
674
743
|
console.error("Google Sign-In not loaded");
|
|
744
|
+
onErrorRef.current?.("Google Sign-In could not be loaded. Check your connection or any script blockers, then try again.");
|
|
675
745
|
return;
|
|
676
746
|
}
|
|
747
|
+
onErrorRef.current?.(null);
|
|
677
748
|
codeClientRef.current.requestCode();
|
|
678
749
|
};
|
|
679
750
|
|
package/src/core/Rebase.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import type { ComponentOverrideMap } from "@rebasepro/admin-types";
|
|
|
6
6
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
7
7
|
import { CenteredView, Typography } from "@rebasepro/ui";
|
|
8
8
|
import { User, CollectionRegistryController, DataDriver, DataSourceDefinition, RebaseData, DEFAULT_DATA_SOURCE_KEY, StorageSource, StorageSourceDefinition, DEFAULT_STORAGE_SOURCE_KEY } from "@rebasepro/types";
|
|
9
|
-
import { RebaseContext, UNRENDERED_SLOTS } from "@rebasepro/admin-types";
|
|
9
|
+
import { RebaseContext, RENAMED_SLOTS, UNRENDERED_SLOTS } from "@rebasepro/admin-types";
|
|
10
10
|
import { PluginProviderStack } from "./PluginProviderStack";
|
|
11
11
|
import { PluginLifecycleManager } from "./PluginLifecycleManager";
|
|
12
12
|
import { AuthControllerContext, CollectionResolverRegistrationContext, CollectionResolver } from "../contexts";
|
|
@@ -66,6 +66,7 @@ export function Rebase<USER extends User, DB = unknown>(props: RebaseProps<USER,
|
|
|
66
66
|
onAnalyticsEvent,
|
|
67
67
|
propertyConfigs,
|
|
68
68
|
entityViews,
|
|
69
|
+
collectionViews,
|
|
69
70
|
entityActions,
|
|
70
71
|
|
|
71
72
|
effectiveRoleController,
|
|
@@ -84,11 +85,32 @@ export function Rebase<USER extends User, DB = unknown>(props: RebaseProps<USER,
|
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
// Merge direct slots with plugin slots
|
|
88
|
+
// Merge direct slots with plugin slots, rewriting any retired slot name to
|
|
89
|
+
// the one it was renamed to. `useSlot` matches on string equality, so a
|
|
90
|
+
// contribution left on the old name would register cleanly and render
|
|
91
|
+
// nothing — the same silent nothing `UNRENDERED_SLOTS` warns about below.
|
|
88
92
|
const resolvedSlots: SlotContribution[] = useMemo(() => [
|
|
89
93
|
...directSlots,
|
|
90
94
|
...((plugins ?? []).flatMap((p) => p.slots ?? []))
|
|
91
|
-
]
|
|
95
|
+
].map((contribution) => {
|
|
96
|
+
const current = RENAMED_SLOTS[contribution.slot];
|
|
97
|
+
return current ? { ...contribution, slot: current } : contribution;
|
|
98
|
+
}), [directSlots, plugins]);
|
|
99
|
+
|
|
100
|
+
// Warn once per retired name actually in use. This reads the *pre-rewrite*
|
|
101
|
+
// names off the inputs rather than `resolvedSlots`, which no longer has
|
|
102
|
+
// them — the rewrite above is what makes the plugin keep working, and this
|
|
103
|
+
// is what tells its author to stop relying on it.
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
const registered = [...directSlots, ...((plugins ?? []).flatMap((p) => p.slots ?? []))];
|
|
106
|
+
for (const legacy of new Set(registered.map(s => s.slot).filter(slot => slot in RENAMED_SLOTS))) {
|
|
107
|
+
console.warn(
|
|
108
|
+
`[Rebase] The "${legacy}" slot has been renamed to "${RENAMED_SLOTS[legacy]}". ` +
|
|
109
|
+
"The contribution was redirected and still renders, but the old name will be " +
|
|
110
|
+
"removed in a future major version."
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}, [directSlots, plugins]);
|
|
92
114
|
|
|
93
115
|
// Seven of the twenty-nine declared slots are rendered nowhere. They have
|
|
94
116
|
// props interfaces and rows in the public slot reference, so registering
|
|
@@ -349,10 +371,11 @@ export function Rebase<USER extends User, DB = unknown>(props: RebaseProps<USER,
|
|
|
349
371
|
plugins,
|
|
350
372
|
resolvedSlots,
|
|
351
373
|
entityViews: entityViews ?? [],
|
|
374
|
+
collectionViews: collectionViews ?? [],
|
|
352
375
|
entityActions: entityActions ?? [],
|
|
353
376
|
propertyConfigs: propertyConfigs ?? {},
|
|
354
377
|
components: componentsProp
|
|
355
|
-
}), [dateTimeFormat, locale, entityLinkBuilder, plugins, resolvedSlots, entityViews, entityActions, propertyConfigs, componentsProp]);
|
|
378
|
+
}), [dateTimeFormat, locale, entityLinkBuilder, plugins, resolvedSlots, entityViews, collectionViews, entityActions, propertyConfigs, componentsProp]);
|
|
356
379
|
|
|
357
380
|
const analyticsController = useMemo(() => ({
|
|
358
381
|
onAnalyticsEvent
|
package/src/core/RebaseProps.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { User, DataDriver, DataSourceDefinition, StorageSource, StorageSourceDefinition, CollectionRegistryController, DatabaseAdmin, RebaseClient } from "@rebasepro/types";
|
|
3
|
-
import { Locale, AuthController, AnalyticsEvent, UserConfigurationPersistence, UrlController, NavigationStateController, RebaseContext, EntityLinkBuilder, RebasePlugin, SlotContribution, PropertyConfig, EntityCustomView, EntityAction, RebaseTranslations, ComponentOverrideMap } from "@rebasepro/admin-types";
|
|
3
|
+
import { Locale, AuthController, AnalyticsEvent, UserConfigurationPersistence, UrlController, NavigationStateController, RebaseContext, EntityLinkBuilder, RebasePlugin, SlotContribution, PropertyConfig, EntityCustomView, CollectionCustomView, EntityAction, RebaseTranslations, ComponentOverrideMap } from "@rebasepro/admin-types";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* A data source registered on `<Rebase>`. Extends the shared
|
|
@@ -283,6 +283,12 @@ export type RebaseProps<USER extends User, DB = unknown> = {
|
|
|
283
283
|
*/
|
|
284
284
|
entityViews?: EntityCustomView[];
|
|
285
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Custom collection view modes, available to every collection by `key`.
|
|
288
|
+
* A collection opts into one by naming that key in `admin.customViews`.
|
|
289
|
+
*/
|
|
290
|
+
collectionViews?: CollectionCustomView[];
|
|
291
|
+
|
|
286
292
|
/**
|
|
287
293
|
* Entity Actions
|
|
288
294
|
*/
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, { useMemo } from "react";
|
|
2
|
+
import { lazyChunk } from "@rebasepro/ui";
|
|
2
3
|
import type { ComponentRef, LazyComponentRef } from "@rebasepro/types";
|
|
3
4
|
import { isLazyComponentRef } from "@rebasepro/types";
|
|
4
5
|
|
|
@@ -70,7 +71,9 @@ function getOrCreateLazy<P>(
|
|
|
70
71
|
if (cached) return cached as React.ComponentType<P>;
|
|
71
72
|
|
|
72
73
|
// SAFETY: React.lazy returns LazyExoticComponent which is structurally compatible with ComponentType<P>
|
|
73
|
-
|
|
74
|
+
// `lazyChunk`, not `lazy`: a custom view is a chunk like any other, and a
|
|
75
|
+
// tab left open across a deploy cannot fetch it either.
|
|
76
|
+
const LazyComponent = lazyChunk(loader) as React.ComponentType<P>;
|
|
74
77
|
lazyCache.set(key, LazyComponent);
|
|
75
78
|
return LazyComponent;
|
|
76
79
|
}
|