@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 +14 -3
- package/dist/app/batch-failure.js +1 -1
- package/dist/app/bottom-bar-from-meta.js +30 -0
- package/dist/app/delete/record-diff.js +1 -1
- package/dist/app/index.html +3 -2
- package/dist/app/record-page.js +6 -4
- package/dist/app-shell/app-shell.d.ts +1 -0
- package/dist/app-shell/app-shell.js +27 -14
- package/dist/app-shell/bottom-bar.d.ts +1 -0
- package/dist/app-shell/bottom-bar.js +57 -0
- package/dist/app-shell/index.d.ts +1 -0
- package/dist/app-shell/profile-menu.js +35 -26
- package/dist/app-shell/sidebar.js +3 -2
- package/dist/app-shell/switch-group.js +29 -0
- package/dist/auth/auth-provider.js +5 -1
- package/dist/auth/sign-in-form.js +23 -12
- package/dist/auth/sign-in-page.js +3 -2
- package/dist/data/meta-data.d.ts +3 -1
- package/dist/data/use-meta.js +1 -0
- package/dist/live/audit-fields.js +5 -0
- package/dist/live/naming.js +2 -2
- package/dist/roles/acting-user.js +11 -0
- package/dist/roles/use-active-role.js +33 -0
- package/package.json +4 -4
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
|
|
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(
|
|
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
|
|
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 [];
|
package/dist/app/index.html
CHANGED
|
@@ -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
|
-
|
|
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>
|
package/dist/app/record-page.js
CHANGED
|
@@ -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
|
|
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: ["
|
|
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,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: [
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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,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 {
|
|
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__ */
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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: [
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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);
|
package/dist/data/meta-data.d.ts
CHANGED
|
@@ -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;
|
package/dist/data/use-meta.js
CHANGED
|
@@ -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 };
|
package/dist/live/naming.js
CHANGED
|
@@ -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`
|
|
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.
|
|
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.
|
|
38
|
-
"@protobase/layout": "0.
|
|
39
|
-
"@protobase/schema": "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",
|