@protobase/ui 0.9.0 → 0.10.0

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/app/app.js CHANGED
@@ -13,6 +13,7 @@ import { humanize } from "../live/naming.js";
13
13
  import { ToastProvider } from "../toasts/toast-provider.js";
14
14
  import { useProjectUi } from "./pages/project-ui.js";
15
15
  import { AssistantButton, AssistantPanel } from "./assistant-shell.js";
16
+ import { bottomBarFromMeta } from "./bottom-bar-from-meta.js";
16
17
  import { breadcrumbFor } from "./breadcrumb-for.js";
17
18
  import { Router, matchRoute, useRouter } from "./router.js";
18
19
  import { MetaGate, Notice, useAdminMeta } from "./meta-gate.js";
@@ -23,6 +24,8 @@ import { InvitationsPage } from "../organizations/invitations-page.js";
23
24
  import { reopenIn, useOrganizations } from "../organizations/use-organizations.js";
24
25
  import { OrganizationPage } from "../organizations/organization-page.js";
25
26
  import { OrganizationsPage } from "../organizations/organizations-page.js";
27
+ import { actingUser } from "../roles/acting-user.js";
28
+ import { useActiveRole } from "../roles/use-active-role.js";
26
29
  import { userMenuFromMeta } from "./user-menu-from-meta.js";
27
30
  import { navFromMeta } from "./nav-from-meta.js";
28
31
  import { ComposedPage } from "./pages/composed-page.js";
@@ -32,7 +35,7 @@ import { useGlobalSearch } from "./search/use-global-search.js";
32
35
  import { RuntimeUpdateButton } from "./runtime/runtime-update.js";
33
36
  import { StaffBanner } from "./staff-banner.js";
34
37
  import { useNavRecent } from "./use-nav-recent.js";
35
- import { useEffect, useState } from "react";
38
+ import { useCallback, useEffect, useState } from "react";
36
39
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
37
40
  //#region src/app/app.tsx
38
41
  const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
@@ -80,7 +83,9 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
80
83
  url: runtimeUrl,
81
84
  client: runtime
82
85
  });
83
- const admin = isAdmin(user);
86
+ const role = useActiveRole(useCallback(() => reopenIn(basePath), [basePath]));
87
+ const acting = actingUser(user, role.current);
88
+ const admin = isAdmin(acting);
84
89
  const organizations = useOrganizations(Boolean(organizationSettings));
85
90
  const current = organizations.current;
86
91
  const outsideOrganizations = Boolean(organizationSettings) && current !== void 0 && !current.organization;
@@ -118,10 +123,11 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
118
123
  sidebarMode: sidebarMode ?? "text-small",
119
124
  activeItem: route.resource ?? "",
120
125
  breadcrumb,
121
- user: userToShell(user),
126
+ user: userToShell(acting),
122
127
  onSignOut: () => void signOut(),
123
128
  workspace: current?.organization?.name ?? workspace,
124
129
  navGroups: groups,
130
+ bottomBar: bottomBarFromMeta(meta, basePath, route.resource),
125
131
  userMenu: [
126
132
  ...userMenuFromMeta(meta, basePath, route.resource),
127
133
  ...organizationSettings ? organizationMenuItems(basePath, path, current) : [],
@@ -135,6 +141,11 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
135
141
  others: organizations.organizations.filter((organization) => organization.id !== current?.organization?.id),
136
142
  onSwitch: (id) => void session.organizations.switchTo(id).then(() => reopenIn(basePath))
137
143
  },
144
+ roles: role.current?.active ? {
145
+ active: role.current.active,
146
+ roles: role.current.roles,
147
+ onSwitch: role.switchTo
148
+ } : void 0,
138
149
  onNavigate: open,
139
150
  search: search.available ? {
140
151
  placeholder: search.placeholder,
@@ -4,7 +4,7 @@ const label = (op) => {
4
4
  const resource = typeof op.resource === "string" ? op.resource : op.resource.toModel().name;
5
5
  return op.op === "reorder" ? `reorder ${resource} by ${op.field}` : `${op.op} ${resource}`;
6
6
  };
7
- /** "Operation 2 (reorder invoiceLines by position) failed: ..." so the person knows which part was refused. */
7
+ /** "Operation 2 (reorder invoice_lines by position) failed: ..." so the person knows which part was refused. */
8
8
  const describeBatchFailure = (error, ops) => {
9
9
  const index = error.operation;
10
10
  const op = index === void 0 ? void 0 : ops[index];
@@ -0,0 +1,30 @@
1
+ import { iconFor } from "./icons.js";
2
+ import { menuIcon } from "./menu-icons.js";
3
+ import { ExternalLink, LayoutDashboard } from "lucide-react";
4
+ //#region src/app/bottom-bar-from-meta.ts
5
+ /** The bottom bar's tabs from `/meta`: resources and pages open in the app (active while open), links open a new tab. */
6
+ const bottomBarFromMeta = (meta, basePath, activeResource) => (meta.bottomBar?.items ?? []).map((item, index) => {
7
+ if (item.kind === "link") return {
8
+ id: `link-${index}`,
9
+ label: item.label,
10
+ icon: menuIcon(item.icon, ExternalLink),
11
+ href: item.href,
12
+ external: true
13
+ };
14
+ if (item.kind === "page") return {
15
+ id: item.page,
16
+ label: item.label,
17
+ icon: menuIcon(item.icon ?? meta.pages[item.page]?.icon, LayoutDashboard),
18
+ href: `${basePath}/${item.page}`,
19
+ active: item.page === activeResource
20
+ };
21
+ return {
22
+ id: item.resource,
23
+ label: item.label,
24
+ icon: menuIcon(item.icon, iconFor(item.resource)),
25
+ href: `${basePath}/${item.resource}`,
26
+ active: item.resource === activeResource
27
+ };
28
+ });
29
+ //#endregion
30
+ export { bottomBarFromMeta };
@@ -1,7 +1,7 @@
1
1
  import { humanize } from "../../live/naming.js";
2
2
  import { displayValue } from "../../live/display-value.js";
3
3
  //#region src/app/delete/record-diff.ts
4
- const volatile = /* @__PURE__ */ new Set(["updatedAt"]);
4
+ const volatile = /* @__PURE__ */ new Set(["updated_at", "updatedAt"]);
5
5
  /** The fields whose stored value differs between two reads of a record, for the "changed since you opened it" dialog. */
6
6
  const diffRecords = (model, view, before, after) => Object.values(model.fields).flatMap((field) => {
7
7
  if (volatile.has(field.name) || JSON.stringify(before[field.name] ?? null) === JSON.stringify(after[field.name] ?? null)) return [];
@@ -2,11 +2,12 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
6
  <title>Protobase</title>
7
7
  </head>
8
8
  <body>
9
- <div id="root" class="h-screen"></div>
9
+ <!-- The page reaches under the iPhone home indicator, which the bottom bar keeps clear of; the sides keep clear of the notch. -->
10
+ <div id="root" class="h-screen" style="padding-left: env(safe-area-inset-left); padding-right: env(safe-area-inset-right)"></div>
10
11
  <script type="module" src="./main"></script>
11
12
  </body>
12
13
  </html>
@@ -20,6 +20,7 @@ import { RecordHeader } from "../record-view/record-header.js";
20
20
  import { toneFor } from "../live/columns-from-model.js";
21
21
  import { useDeleteFlow } from "./delete/use-delete-flow.js";
22
22
  import { useRestore } from "./delete/use-restore.js";
23
+ import { auditField } from "../live/audit-fields.js";
23
24
  import { RecordActions } from "./pages/record-actions.js";
24
25
  import { RecordLines, findLinesResource } from "./record-lines.js";
25
26
  import { draftStorageKey } from "./draft-storage.js";
@@ -118,7 +119,8 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
118
119
  const layout = layoutFor(model, view);
119
120
  const sidebarFields = layout.flatMap((item) => item.kind === "sidebar" ? item.fields : []);
120
121
  const status = model.fields.status?.type === "enum" ? String(stored.record.status) : void 0;
121
- const updated = stored.record.updatedAt ? Math.max(0, Math.round((Date.now() - Date.parse(String(stored.record.updatedAt))) / 1e3)) : void 0;
122
+ const updatedAt = auditField(model, "updated");
123
+ const updated = updatedAt && stored.record[updatedAt] ? Math.max(0, Math.round((Date.now() - Date.parse(String(stored.record[updatedAt]))) / 1e3)) : void 0;
122
124
  const hint = (name) => view?.fields[name];
123
125
  const visible = (name) => fieldAccess(stored.permissions, name) !== "hidden";
124
126
  const fieldView = (name) => {
@@ -288,9 +290,9 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
288
290
  }),
289
291
  /* @__PURE__ */ jsx(MetadataCard, {
290
292
  title: "Details",
291
- rows: ["createdAt", "updatedAt"].flatMap((name) => {
292
- const field = model.fields[name];
293
- return field && visible(name) ? [{
293
+ rows: [auditField(model, "created"), updatedAt].flatMap((name) => {
294
+ const field = name ? model.fields[name] : void 0;
295
+ return name && field && visible(name) ? [{
294
296
  label: hint(name)?.label ?? humanize(name),
295
297
  value: displayValue(field, stored.record[name], hint(name))
296
298
  }] : [];
@@ -1,3 +1,4 @@
1
+ import "./bottom-bar.js";
1
2
  import "./nav.js";
2
3
  import "./profile-menu.js";
3
4
  import "./global-search.js";
@@ -1,5 +1,6 @@
1
1
  import { cn } from "../lib/cn.js";
2
2
  import { useIsDesktop } from "../lib/use-media-query.js";
3
+ import { BottomBar } from "./bottom-bar.js";
3
4
  import { Drawer } from "./drawer.js";
4
5
  import { Sidebar } from "./sidebar.js";
5
6
  import { TopBar } from "./top-bar.js";
@@ -7,12 +8,13 @@ import { useEffect, useState } from "react";
7
8
  import { jsx, jsxs } from "react/jsx-runtime";
8
9
  //#region src/app-shell/app-shell.tsx
9
10
  const drawerId = "app-drawer";
10
- const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, actions, rightPanel, forceExpanded, profileMenuOpen, navGroups, userMenu, onNavigate, onSignOut, organizations, search, className, children }) => {
11
+ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, actions, rightPanel, forceExpanded, profileMenuOpen, navGroups, userMenu, bottomBar, onNavigate, onSignOut, organizations, roles, search, className, children }) => {
11
12
  const desktop = useIsDesktop();
12
13
  const [drawerOpen, setDrawerOpen] = useState(false);
13
14
  useEffect(() => {
14
15
  if (desktop) setDrawerOpen(false);
15
16
  }, [desktop]);
17
+ const tabs = !desktop && bottomBar && bottomBar.length > 0 ? bottomBar : void 0;
16
18
  const sidebar = (mode) => /* @__PURE__ */ jsx(Sidebar, {
17
19
  mode,
18
20
  activeItem,
@@ -24,7 +26,8 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, action
24
26
  userMenu,
25
27
  onNavigate,
26
28
  onSignOut,
27
- organizations
29
+ organizations,
30
+ roles
28
31
  });
29
32
  return /* @__PURE__ */ jsxs("div", {
30
33
  className: cn("flex h-full min-h-0 w-full overflow-hidden bg-background", className),
@@ -37,18 +40,28 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, action
37
40
  }),
38
41
  /* @__PURE__ */ jsxs("div", {
39
42
  className: "flex min-w-0 flex-1 flex-col",
40
- children: [/* @__PURE__ */ jsx(TopBar, {
41
- breadcrumb,
42
- onNavigate,
43
- actions,
44
- onMenu: desktop ? void 0 : () => setDrawerOpen(true),
45
- menuOpen: drawerOpen,
46
- menuControls: drawerId,
47
- search
48
- }), /* @__PURE__ */ jsx("main", {
49
- className: "flex min-h-0 flex-1 flex-col",
50
- children
51
- })]
43
+ children: [
44
+ /* @__PURE__ */ jsx(TopBar, {
45
+ breadcrumb,
46
+ onNavigate,
47
+ actions,
48
+ onMenu: desktop || tabs ? void 0 : () => setDrawerOpen(true),
49
+ menuOpen: drawerOpen,
50
+ menuControls: drawerId,
51
+ search
52
+ }),
53
+ /* @__PURE__ */ jsx("main", {
54
+ className: "flex min-h-0 flex-1 flex-col",
55
+ children
56
+ }),
57
+ tabs && /* @__PURE__ */ jsx(BottomBar, {
58
+ tabs,
59
+ onNavigate,
60
+ onMore: () => setDrawerOpen(true),
61
+ moreOpen: drawerOpen,
62
+ moreControls: drawerId
63
+ })
64
+ ]
52
65
  }),
53
66
  rightPanel
54
67
  ]
@@ -0,0 +1 @@
1
+ import "lucide-react";
@@ -0,0 +1,57 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { Ellipsis } from "lucide-react";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/app-shell/bottom-bar.tsx
5
+ const inApp = (event) => event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
6
+ const tab = "flex min-h-[49px] min-w-0 flex-1 flex-col items-center justify-center gap-1 pb-1 pt-1.5 text-[10px] font-medium leading-none";
7
+ /**
8
+ * Tabs along the bottom of a phone, as on iOS: icon above label, the current screen in the accent colour, and "More"
9
+ * last, which opens the navigation drawer and is the current tab on screens the others do not hold. The padding below
10
+ * keeps the tabs clear of the iPhone home indicator.
11
+ */
12
+ const BottomBar = ({ tabs, onNavigate, onMore, moreOpen, moreControls }) => {
13
+ const moreCurrent = !tabs.some((item) => item.active);
14
+ return /* @__PURE__ */ jsxs("nav", {
15
+ "aria-label": "Tabs",
16
+ className: "flex shrink-0 border-t bg-background pb-[env(safe-area-inset-bottom)] md:hidden",
17
+ children: [tabs.map((item) => {
18
+ const Icon = item.icon;
19
+ return /* @__PURE__ */ jsxs("a", {
20
+ href: item.href,
21
+ "aria-current": item.active ? "page" : void 0,
22
+ ...item.external ? {
23
+ target: "_blank",
24
+ rel: "noreferrer"
25
+ } : {},
26
+ onClick: (event) => {
27
+ if (item.external || !onNavigate || !inApp(event)) return;
28
+ event.preventDefault();
29
+ onNavigate(item.href);
30
+ },
31
+ className: cn(tab, item.active ? "text-primary-text" : "text-muted-foreground"),
32
+ children: [/* @__PURE__ */ jsx(Icon, {
33
+ className: "size-6",
34
+ strokeWidth: item.active ? 2.2 : 1.9,
35
+ "aria-hidden": true
36
+ }), /* @__PURE__ */ jsx("span", {
37
+ className: "max-w-full truncate px-0.5",
38
+ children: item.label
39
+ })]
40
+ }, item.id);
41
+ }), /* @__PURE__ */ jsxs("button", {
42
+ type: "button",
43
+ "aria-expanded": moreOpen,
44
+ "aria-controls": moreControls,
45
+ "data-current": moreCurrent || void 0,
46
+ onClick: onMore,
47
+ className: cn(tab, moreCurrent ? "text-primary-text" : "text-muted-foreground"),
48
+ children: [/* @__PURE__ */ jsx(Ellipsis, {
49
+ className: "size-6",
50
+ strokeWidth: moreCurrent ? 2.2 : 1.9,
51
+ "aria-hidden": true
52
+ }), "More"]
53
+ })]
54
+ });
55
+ };
56
+ //#endregion
57
+ export { BottomBar };
@@ -1,3 +1,4 @@
1
+ import "./bottom-bar.js";
1
2
  import "./nav.js";
2
3
  import "./profile-menu.js";
3
4
  import { SidebarMode, sidebarModes } from "./sidebar-mode.js";
@@ -1,13 +1,15 @@
1
1
  import { cn } from "../lib/cn.js";
2
2
  import { Avatar } from "../primitives/avatar.js";
3
3
  import { Badge } from "../primitives/badge.js";
4
+ import { SwitchGroup } from "./switch-group.js";
4
5
  import { useEffect, useRef, useState } from "react";
5
- import { Check, ChevronsUpDown, LogOut } from "lucide-react";
6
+ import { ChevronsUpDown, LogOut } from "lucide-react";
6
7
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
7
8
  //#region src/app-shell/profile-menu.tsx
8
9
  const menuRow = "flex min-h-11 w-full items-center gap-2.5 px-3 py-1.5 text-left text-[13px] text-muted-foreground hover:bg-muted md:min-h-0";
9
10
  /** The person at the bottom of the sidebar; the menu is fixed to the viewport so a narrow sidebar cannot clip it. */
10
- const ProfileMenu = ({ user, compact, open: controlled, items = [], onNavigate, onSignOut, organizations }) => {
11
+ const ProfileMenu = ({ user, compact, open: controlled, items = [], onNavigate, onSignOut, organizations, roles }) => {
12
+ const activeRole = roles?.roles.find((role) => role.name === roles.active);
11
13
  const [local, setLocal] = useState(false);
12
14
  const open = controlled ?? local;
13
15
  const root = useRef(null);
@@ -77,30 +79,37 @@ const ProfileMenu = ({ user, compact, open: controlled, items = [], onNavigate,
77
79
  children: user.email
78
80
  })]
79
81
  }),
80
- organizations && (organizations.current || organizations.others.length > 0) && /* @__PURE__ */ jsxs("div", {
81
- className: "border-b py-1",
82
- role: "group",
83
- "aria-label": "Organizations",
84
- children: [organizations.current && /* @__PURE__ */ jsxs("div", {
85
- className: cn(menuRow, "text-foreground hover:bg-transparent"),
86
- "aria-current": "true",
87
- children: [/* @__PURE__ */ jsx(Check, { className: "size-4" }), /* @__PURE__ */ jsx("span", {
88
- className: "truncate",
89
- children: organizations.current.name
90
- })]
91
- }), organizations.others.map((organization) => /* @__PURE__ */ jsxs("button", {
92
- type: "button",
93
- role: "menuitem",
94
- onClick: () => {
95
- setLocal(false);
96
- organizations.onSwitch(organization.id);
97
- },
98
- className: menuRow,
99
- children: [/* @__PURE__ */ jsx("span", { className: "size-4" }), /* @__PURE__ */ jsx("span", {
100
- className: "truncate",
101
- children: organization.name
102
- })]
103
- }, organization.id))]
82
+ organizations && (organizations.current || organizations.others.length > 0) && /* @__PURE__ */ jsx(SwitchGroup, {
83
+ label: "Organizations",
84
+ ...organizations.current && { current: {
85
+ id: organizations.current.id,
86
+ label: organizations.current.name
87
+ } },
88
+ others: organizations.others.map(({ id, name }) => ({
89
+ id,
90
+ label: name
91
+ })),
92
+ rowClassName: menuRow,
93
+ onSwitch: (id) => {
94
+ setLocal(false);
95
+ organizations.onSwitch(id);
96
+ }
97
+ }),
98
+ roles && roles.roles.length > 1 && /* @__PURE__ */ jsx(SwitchGroup, {
99
+ label: "Roles",
100
+ ...activeRole && { current: {
101
+ id: activeRole.name,
102
+ label: activeRole.label
103
+ } },
104
+ others: roles.roles.filter((role) => role !== activeRole).map(({ name, label }) => ({
105
+ id: name,
106
+ label
107
+ })),
108
+ rowClassName: menuRow,
109
+ onSwitch: (name) => {
110
+ setLocal(false);
111
+ roles.onSwitch(name);
112
+ }
104
113
  }),
105
114
  items.length > 0 && /* @__PURE__ */ jsx("div", {
106
115
  className: cn(onSignOut && "border-b"),
@@ -7,7 +7,7 @@ import { SidebarItem } from "./sidebar-item.js";
7
7
  import { SidebarGroup } from "./sidebar-group.js";
8
8
  import { jsx, jsxs } from "react/jsx-runtime";
9
9
  //#region src/app-shell/sidebar.tsx
10
- const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenuOpen, groups = navGroups, userMenu, onNavigate, onSignOut, organizations }) => {
10
+ const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenuOpen, groups = navGroups, userMenu, onNavigate, onSignOut, organizations, roles }) => {
11
11
  const expanding = mode === "icon-expand";
12
12
  const expanded = expanding && forceExpanded;
13
13
  const inline = isTextMode(mode) || expanding;
@@ -54,7 +54,8 @@ const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenu
54
54
  items: userMenu,
55
55
  onNavigate,
56
56
  onSignOut,
57
- organizations
57
+ organizations,
58
+ roles
58
59
  })
59
60
  })
60
61
  ]
@@ -0,0 +1,29 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { Check } from "lucide-react";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/app-shell/switch-group.tsx
5
+ /** A group of the profile menu that switches between options: the current one checked, the others a click away. */
6
+ const SwitchGroup = ({ label, current, others, rowClassName, onSwitch }) => /* @__PURE__ */ jsxs("div", {
7
+ className: "border-b py-1",
8
+ role: "group",
9
+ "aria-label": label,
10
+ children: [current && /* @__PURE__ */ jsxs("div", {
11
+ className: cn(rowClassName, "text-foreground hover:bg-transparent"),
12
+ "aria-current": "true",
13
+ children: [/* @__PURE__ */ jsx(Check, { className: "size-4" }), /* @__PURE__ */ jsx("span", {
14
+ className: "truncate",
15
+ children: current.label
16
+ })]
17
+ }), others.map((option) => /* @__PURE__ */ jsxs("button", {
18
+ type: "button",
19
+ role: "menuitem",
20
+ onClick: () => onSwitch(option.id),
21
+ className: rowClassName,
22
+ children: [/* @__PURE__ */ jsx("span", { className: "size-4" }), /* @__PURE__ */ jsx("span", {
23
+ className: "truncate",
24
+ children: option.label
25
+ })]
26
+ }, option.id))]
27
+ });
28
+ //#endregion
29
+ export { SwitchGroup };
@@ -14,6 +14,7 @@ const AuthProvider = ({ session: given, children }) => {
14
14
  const [state, setState] = useState({ kind: "loading" });
15
15
  const [signInMethods, setSignInMethods] = useState(["password"]);
16
16
  const [passwordReset, setPasswordReset] = useState(false);
17
+ const [passkeyDomains, setPasskeyDomains] = useState([]);
17
18
  const [socialProviders, setSocialProviders] = useState([]);
18
19
  const [platformSignIn, setPlatformSignIn] = useState();
19
20
  const [staffSignIn, setStaffSignIn] = useState();
@@ -43,6 +44,7 @@ const AuthProvider = ({ session: given, children }) => {
43
44
  session.status().then(async (status) => {
44
45
  setSignInMethods(status.signInMethods);
45
46
  setPasswordReset(status.passwordReset);
47
+ setPasskeyDomains(status.passkeyDomains ?? []);
46
48
  setSocialProviders(status.socialProviders);
47
49
  setPlatformSignIn(status.platformSignIn);
48
50
  setStaffSignIn(status.staffSignIn);
@@ -61,6 +63,7 @@ const AuthProvider = ({ session: given, children }) => {
61
63
  session,
62
64
  signInMethods,
63
65
  passwordReset,
66
+ passkeyDomains,
64
67
  socialProviders,
65
68
  signInProviders: socialProviders.map((id) => ({
66
69
  id,
@@ -73,7 +76,7 @@ const AuthProvider = ({ session: given, children }) => {
73
76
  signIn: async (email, password) => finish(await session.signIn(email, password)),
74
77
  sendSignInCode: (email) => session.sendSignInCode(email),
75
78
  signInWithCode: async (email, code) => finish(await session.signInWithCode(email, code)),
76
- signInWithPasskey: async () => enter(await session.signInWithPasskey()),
79
+ signInWithPasskey: async (domain) => enter(await session.signInWithPasskey(domain)),
77
80
  verifyTwoFactor: async (input) => enter(await session.verifyTwoFactor(input)),
78
81
  signInSocial: async (provider) => {
79
82
  window.location.assign(await session.signInSocial(provider, new URL(withoutSignInLink(window.location.href), window.location.href).href));
@@ -94,6 +97,7 @@ const AuthProvider = ({ session: given, children }) => {
94
97
  session,
95
98
  signInMethods,
96
99
  passwordReset,
100
+ passkeyDomains,
97
101
  socialProviders,
98
102
  platformSignIn,
99
103
  staffSignIn,
@@ -13,7 +13,7 @@ const Divider = () => /* @__PURE__ */ jsxs("div", {
13
13
  /* @__PURE__ */ jsx("span", { className: "h-px flex-1 bg-border" })
14
14
  ]
15
15
  });
16
- const SignInForm = ({ onSubmit, busy, error, workspace, notice, methods = ["password"], onSendCode, onPasskey, onForgotPassword, providers = [], onContinueWith }) => {
16
+ const SignInForm = ({ onSubmit, busy, error, workspace, notice, methods = ["password"], onSendCode, onPasskey, passkeyDomains = [], onForgotPassword, providers = [], onContinueWith }) => {
17
17
  const [email, setEmail] = useState("");
18
18
  const [password, setPassword] = useState("");
19
19
  const emailInput = useRef(null);
@@ -36,17 +36,28 @@ const SignInForm = ({ onSubmit, busy, error, workspace, notice, methods = ["pass
36
36
  }),
37
37
  (offered.length > 0 || withPasskey) && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("div", {
38
38
  className: "flex flex-col gap-2",
39
- children: [offered.map((provider) => /* @__PURE__ */ jsxs(Button, {
40
- onClick: () => onContinueWith?.(provider.id),
41
- disabled: busy,
42
- className: "min-h-10 w-full",
43
- children: ["Continue with ", provider.name]
44
- }, provider.id)), withPasskey && /* @__PURE__ */ jsxs(Button, {
45
- onClick: onPasskey,
46
- disabled: busy,
47
- className: "min-h-10 w-full",
48
- children: [/* @__PURE__ */ jsx(KeyRound, { className: "size-4" }), "Sign in with a passkey"]
49
- })]
39
+ children: [
40
+ offered.map((provider) => /* @__PURE__ */ jsxs(Button, {
41
+ onClick: () => onContinueWith?.(provider.id),
42
+ disabled: busy,
43
+ className: "min-h-10 w-full",
44
+ children: ["Continue with ", provider.name]
45
+ }, provider.id)),
46
+ withPasskey && /* @__PURE__ */ jsxs(Button, {
47
+ onClick: () => onPasskey?.(),
48
+ disabled: busy,
49
+ className: "min-h-10 w-full",
50
+ children: [/* @__PURE__ */ jsx(KeyRound, { className: "size-4" }), "Sign in with a passkey"]
51
+ }),
52
+ withPasskey && passkeyDomains.slice(1).map((domain) => /* @__PURE__ */ jsxs(Button, {
53
+ variant: "ghost",
54
+ size: "sm",
55
+ onClick: () => onPasskey?.(domain),
56
+ disabled: busy,
57
+ className: "w-full",
58
+ children: ["Use a passkey made for ", domain]
59
+ }, domain))
60
+ ]
50
61
  }), withPassword || withCode ? /* @__PURE__ */ jsx(Divider, {}) : error && /* @__PURE__ */ jsx("p", {
51
62
  role: "alert",
52
63
  className: "mt-4 text-xs font-medium text-danger-text",
@@ -6,7 +6,7 @@ import { AuthError } from "@protobase/client";
6
6
  import { jsx } from "react/jsx-runtime";
7
7
  //#region src/auth/sign-in-page.tsx
8
8
  const SignInPage = ({ workspace, notice, onForgotPassword, onCodeSent, error: initialError }) => {
9
- const { signIn, signInSocial, signInProviders, signInMethods, sendSignInCode, signInWithPasskey } = useAuth();
9
+ const { signIn, signInSocial, signInProviders, signInMethods, passkeyDomains, sendSignInCode, signInWithPasskey } = useAuth();
10
10
  const [busy, setBusy] = useState(false);
11
11
  const [error, setError] = useState(initialError);
12
12
  const attempt = async (start, message) => {
@@ -29,7 +29,8 @@ const SignInPage = ({ workspace, notice, onForgotPassword, onCodeSent, error: in
29
29
  onForgotPassword,
30
30
  providers: signInProviders,
31
31
  onContinueWith: (provider) => void attempt(() => signInSocial(provider), signInMessage),
32
- onPasskey: () => void attempt(signInWithPasskey, authMessage),
32
+ passkeyDomains,
33
+ onPasskey: (domain) => void attempt(() => signInWithPasskey(domain), authMessage),
33
34
  onSendCode: (email) => void attempt(async () => {
34
35
  await sendSignInCode(email);
35
36
  onCodeSent(email);
@@ -1,5 +1,5 @@
1
1
  import { ResourcePermissions } from "@protobase/client";
2
- import { ResourceModel, UserMenuModel, ViewModel } from "@protobase/schema";
2
+ import { BottomBarModel, ResourceModel, UserMenuModel, ViewModel } from "@protobase/schema";
3
3
  import { PageModel } from "@protobase/layout";
4
4
  //#region src/data/meta-data.d.ts
5
5
  export type MetaData = {
@@ -10,6 +10,8 @@ export type MetaData = {
10
10
  pages: Record<string, PageModel>;
11
11
  permissions: Record<string, ResourcePermissions>;
12
12
  userMenu?: UserMenuModel;
13
+ /** The tabs of the user's bottom bar on phones, when one names their active role. */
14
+ bottomBar?: Omit<BottomBarModel, 'roles'>;
13
15
  /** The assistant backend, when the app has one and the user has the admin or ai role. */
14
16
  assistant?: {
15
17
  url: string;
@@ -27,6 +27,7 @@ const useMeta = () => {
27
27
  pages: Object.fromEntries((result.meta.pages ?? []).map((model) => [model.name, model])),
28
28
  permissions: result.meta.permissions ?? {},
29
29
  ...result.meta.userMenu && { userMenu: result.meta.userMenu },
30
+ ...result.meta.bottomBar && { bottomBar: result.meta.bottomBar },
30
31
  ...result.meta.assistant && { assistant: result.meta.assistant },
31
32
  ...result.meta.runtime && { runtime: result.meta.runtime }
32
33
  };
@@ -0,0 +1,5 @@
1
+ //#region src/live/audit-fields.ts
2
+ /** The field that holds when a record was created or last updated: `created_at` or `createdAt`, as the model names it. */
3
+ const auditField = (model, kind) => [`${kind}_at`, `${kind}At`].find((name) => model.fields[name]);
4
+ //#endregion
5
+ export { auditField };
@@ -1,8 +1,8 @@
1
1
  //#region src/live/naming.ts
2
2
  const capitalise = (text) => text.charAt(0).toUpperCase() + text.slice(1);
3
- /** `companyId` becomes "Company", `discountPercent` becomes "Discount percent". */
3
+ /** `companyId` and `company_id` become "Company", `discountPercent` and `discount_percent` become "Discount percent". */
4
4
  const humanize = (name) => {
5
- const words = name.replace(/(Id|Code)$/, "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
5
+ const words = name.replace(/(Id|Code|_id|_code)$/, "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/_+/g, " ").trim().toLowerCase();
6
6
  return capitalise(words || name);
7
7
  };
8
8
  //#endregion
@@ -0,0 +1,11 @@
1
+ //#region src/roles/acting-user.ts
2
+ /**
3
+ * The signed-in user as the session acts: with the active role alone, as its tokens carry it, so what the shell shows
4
+ * by role follows a switch. As signed in until the active role is read, or for someone without a role.
5
+ */
6
+ const actingUser = (user, role) => role?.active ? {
7
+ ...user,
8
+ role: role.active
9
+ } : user;
10
+ //#endregion
11
+ export { actingUser };
@@ -0,0 +1,33 @@
1
+ import { authMessage } from "../auth/auth-messages.js";
2
+ import { useAuth } from "../auth/auth-provider.js";
3
+ import { useToast } from "../toasts/toast-provider.js";
4
+ import { useCallback, useEffect, useState } from "react";
5
+ import { AuthError } from "@protobase/client";
6
+ //#region src/roles/use-active-role.ts
7
+ const failureMessage = (failure, fallback) => failure instanceof AuthError ? authMessage(failure) : fallback;
8
+ /** The role the session acts with; a failed read or switch shows as a toast. */
9
+ const useActiveRole = (reopen) => {
10
+ const { session } = useAuth();
11
+ const toast = useToast();
12
+ const [current, setCurrent] = useState();
13
+ useEffect(() => {
14
+ session.roles.current().then(setCurrent, (failure) => toast.show({
15
+ state: "error",
16
+ title: failureMessage(failure, "Could not read your roles")
17
+ }));
18
+ }, [session, toast]);
19
+ const switchTo = useCallback((name) => void session.roles.switchTo(name).then(reopen, (failure) => toast.show({
20
+ state: "error",
21
+ title: failureMessage(failure, "Could not switch the role")
22
+ })), [
23
+ session,
24
+ toast,
25
+ reopen
26
+ ]);
27
+ return {
28
+ ...current && { current },
29
+ switchTo
30
+ };
31
+ };
32
+ //#endregion
33
+ export { useActiveRole };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/ui",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "The Protobase admin app and the React hooks and components for custom UI.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -34,9 +34,9 @@
34
34
  "dependencies": {
35
35
  "@dnd-kit/core": "^6.3.1",
36
36
  "@dnd-kit/sortable": "^10.0.0",
37
- "@protobase/client": "0.9.0",
38
- "@protobase/layout": "0.9.0",
39
- "@protobase/schema": "0.9.0",
37
+ "@protobase/client": "0.10.0",
38
+ "@protobase/layout": "0.10.0",
39
+ "@protobase/schema": "0.10.0",
40
40
  "@tanstack/react-query": "^5.104.1",
41
41
  "@tanstack/react-table": "^9.2.6",
42
42
  "@tanstack/react-virtual": "^3.14.13",