@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/app",
3
3
  "type": "module",
4
- "version": "0.14.1",
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.1",
56
- "@rebasepro/types": "0.14.1",
57
- "@rebasepro/forms": "0.14.1",
58
- "@rebasepro/common": "0.14.1",
59
- "@rebasepro/utils": "0.14.1",
60
- "@rebasepro/ui": "0.14.1"
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=&quot;{c}&quot;</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
 
@@ -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
- ], [directSlots, plugins]);
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
@@ -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, { lazy, useMemo } from "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
- const LazyComponent = lazy(loader) as React.ComponentType<P>;
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
  }