@protobase/ui 0.7.0 → 0.8.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 +36 -4
- package/dist/app/create/create-page.js +1 -0
- package/dist/app/create/create-values.js +1 -0
- package/dist/app/field-editor.js +11 -1
- package/dist/app/file-field.js +177 -0
- package/dist/app/pages/blocks/modal-form.js +2 -0
- package/dist/app/pages/field-value.js +9 -0
- package/dist/app/record-page.js +1 -0
- package/dist/app/use-record-editor.js +3 -2
- package/dist/app-shell/app-shell.js +3 -2
- package/dist/app-shell/profile-menu.js +27 -2
- package/dist/app-shell/sidebar.js +3 -2
- package/dist/auth/auth-gate.js +26 -4
- package/dist/auth/auth-provider.js +4 -0
- package/dist/data/api-provider.d.ts +2 -0
- package/dist/data-grid/cells.js +22 -1
- package/dist/live/columns-from-model.js +3 -1
- package/dist/live/display-value.js +2 -0
- package/dist/live/field-values.js +4 -1
- package/dist/live/file-values.js +55 -0
- package/dist/organizations/app-roles-picker.js +24 -0
- package/dist/organizations/invitation-link.js +15 -0
- package/dist/organizations/invitation-page.js +158 -0
- package/dist/organizations/invitations-page.js +179 -0
- package/dist/organizations/members-page.js +122 -0
- package/dist/organizations/organization-page.js +204 -0
- package/dist/organizations/organization-paths.js +49 -0
- package/dist/organizations/organizations-page.js +196 -0
- package/dist/organizations/use-action.js +28 -0
- package/dist/organizations/use-organizations.js +38 -0
- package/package.json +4 -4
package/dist/app/app.js
CHANGED
|
@@ -4,6 +4,7 @@ import { AuthProvider, useAuth } from "../auth/auth-provider.js";
|
|
|
4
4
|
import { AccountSecurityPage } from "../account/account-security-page.js";
|
|
5
5
|
import { SignInPolicyPage } from "../account/sign-in-policy-page.js";
|
|
6
6
|
import { ApiProvider } from "../data/api-provider.js";
|
|
7
|
+
import { managesOrganization, organizationMenuItems, organizationPageTitles } from "../organizations/organization-paths.js";
|
|
7
8
|
import { AuthGate } from "../auth/auth-gate.js";
|
|
8
9
|
import { isAdmin, userToShell } from "../auth/user-to-shell.js";
|
|
9
10
|
import { useRecord } from "../data/use-record.js";
|
|
@@ -17,6 +18,11 @@ import { Router, matchRoute, useRouter } from "./router.js";
|
|
|
17
18
|
import { MetaGate, Notice, useAdminMeta } from "./meta-gate.js";
|
|
18
19
|
import { CreatePage } from "./create/create-page.js";
|
|
19
20
|
import { ListPage } from "./list-page.js";
|
|
21
|
+
import { MembersPage } from "../organizations/members-page.js";
|
|
22
|
+
import { InvitationsPage } from "../organizations/invitations-page.js";
|
|
23
|
+
import { reopenIn, useOrganizations } from "../organizations/use-organizations.js";
|
|
24
|
+
import { OrganizationPage } from "../organizations/organization-page.js";
|
|
25
|
+
import { OrganizationsPage } from "../organizations/organizations-page.js";
|
|
20
26
|
import { userMenuFromMeta } from "./user-menu-from-meta.js";
|
|
21
27
|
import { navFromMeta } from "./nav-from-meta.js";
|
|
22
28
|
import { ComposedPage } from "./pages/composed-page.js";
|
|
@@ -31,7 +37,7 @@ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
|
31
37
|
//#region src/app/app.tsx
|
|
32
38
|
const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
|
|
33
39
|
const meta = useAdminMeta();
|
|
34
|
-
const { state, signOut, stopStaffSession } = useAuth();
|
|
40
|
+
const { state, session, signOut, stopStaffSession, organizations: organizationSettings } = useAuth();
|
|
35
41
|
if (state.kind !== "signed-in") throw new Error("The shell is only rendered for a signed-in user");
|
|
36
42
|
const { user, staff } = state;
|
|
37
43
|
const { path, params, basePath, navigate } = useRouter();
|
|
@@ -75,7 +81,11 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
|
|
|
75
81
|
client: runtime
|
|
76
82
|
});
|
|
77
83
|
const admin = isAdmin(user);
|
|
78
|
-
const
|
|
84
|
+
const organizations = useOrganizations(Boolean(organizationSettings));
|
|
85
|
+
const current = organizations.current;
|
|
86
|
+
const outsideOrganizations = Boolean(organizationSettings) && current !== void 0 && !current.organization;
|
|
87
|
+
const organizationPage = organizationSettings ? organizationPageTitles[path] : void 0;
|
|
88
|
+
const accountPage = path === "/-/account" ? "Sign-in & security" : path === "/-/sign-in-policy" ? "Sign-in policy" : organizationPage;
|
|
79
89
|
const breadcrumb = accountPage ? [accountPage] : breadcrumbFor({
|
|
80
90
|
basePath,
|
|
81
91
|
route,
|
|
@@ -87,6 +97,16 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
|
|
|
87
97
|
const content = path === "/-/account" ? /* @__PURE__ */ jsx(AccountSecurityPage, { email: user.email }) : path === "/-/sign-in-policy" ? admin ? /* @__PURE__ */ jsx(SignInPolicyPage, {}) : /* @__PURE__ */ jsx(Notice, {
|
|
88
98
|
title: "Admins only",
|
|
89
99
|
children: "Only an admin can change how people sign in."
|
|
100
|
+
}) : organizationPage && path === "/-/organization" ? /* @__PURE__ */ jsx(OrganizationPage, {
|
|
101
|
+
current,
|
|
102
|
+
basePath,
|
|
103
|
+
onChanged: organizations.reload
|
|
104
|
+
}) : organizationPage && (path === "/-/organization/members" || path === "/-/organization/invitations") ? !managesOrganization(current) ? /* @__PURE__ */ jsx(Notice, {
|
|
105
|
+
title: "Owners and admins only",
|
|
106
|
+
children: "Only the organization's owners and admins manage its members."
|
|
107
|
+
}) : path === "/-/organization/members" ? /* @__PURE__ */ jsx(MembersPage, { current }) : /* @__PURE__ */ jsx(InvitationsPage, { current }) : organizationPage && path === "/-/organizations" || outsideOrganizations ? /* @__PURE__ */ jsx(OrganizationsPage, {
|
|
108
|
+
state: organizations,
|
|
109
|
+
basePath
|
|
90
110
|
}) : !route.resource ? /* @__PURE__ */ jsx(Notice, {
|
|
91
111
|
title: "Choose a resource",
|
|
92
112
|
children: "Pick one from the sidebar."
|
|
@@ -100,9 +120,21 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
|
|
|
100
120
|
breadcrumb,
|
|
101
121
|
user: userToShell(user),
|
|
102
122
|
onSignOut: () => void signOut(),
|
|
103
|
-
workspace,
|
|
123
|
+
workspace: current?.organization?.name ?? workspace,
|
|
104
124
|
navGroups: groups,
|
|
105
|
-
userMenu: [
|
|
125
|
+
userMenu: [
|
|
126
|
+
...userMenuFromMeta(meta, basePath, route.resource),
|
|
127
|
+
...organizationSettings ? organizationMenuItems(basePath, path, current) : [],
|
|
128
|
+
...accountMenuItems(basePath, path, admin)
|
|
129
|
+
],
|
|
130
|
+
organizations: organizationSettings && {
|
|
131
|
+
...current?.organization && { current: {
|
|
132
|
+
id: current.organization.id,
|
|
133
|
+
name: current.organization.name
|
|
134
|
+
} },
|
|
135
|
+
others: organizations.organizations.filter((organization) => organization.id !== current?.organization?.id),
|
|
136
|
+
onSwitch: (id) => void session.organizations.switchTo(id).then(() => reopenIn(basePath))
|
|
137
|
+
},
|
|
106
138
|
onNavigate: open,
|
|
107
139
|
search: search.available ? {
|
|
108
140
|
placeholder: search.placeholder,
|
|
@@ -36,6 +36,7 @@ const createBody = (model, resources, draft) => Object.fromEntries(Object.entrie
|
|
|
36
36
|
if (!field) return [];
|
|
37
37
|
if (isChosen(value)) return [[name, keyValue(resources[field.relation?.resource ?? ""], value.key)]];
|
|
38
38
|
if (field.type === "boolean") return [[name, Boolean(value)]];
|
|
39
|
+
if (field.type === "file") return value ? [[name, toPatchValue(field, value)]] : [];
|
|
39
40
|
if (String(value).trim() === "") return [];
|
|
40
41
|
return [[name, toPatchValue(field, value)]];
|
|
41
42
|
}));
|
package/dist/app/field-editor.js
CHANGED
|
@@ -6,6 +6,7 @@ import { RelationPicker } from "./relation-picker.js";
|
|
|
6
6
|
import { displayValue } from "../live/display-value.js";
|
|
7
7
|
import { isEditable } from "../live/field-values.js";
|
|
8
8
|
import { SensitiveField } from "./sensitive-field.js";
|
|
9
|
+
import { FileField } from "./file-field.js";
|
|
9
10
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
10
11
|
import { enumLabel } from "@protobase/schema";
|
|
11
12
|
//#region src/app/field-editor.tsx
|
|
@@ -14,7 +15,16 @@ const numeric = /* @__PURE__ */ new Set([
|
|
|
14
15
|
"bigint",
|
|
15
16
|
"decimal"
|
|
16
17
|
]);
|
|
17
|
-
const FieldEditor = ({ field, hints, value, stored, relationLabel, relationHref, relationPicker, invalid, locked, placeholder, reveal, onChange }) => {
|
|
18
|
+
const FieldEditor = ({ field, hints, value, stored, relationLabel, relationHref, relationPicker, invalid, locked, placeholder, reveal, resource, onChange }) => {
|
|
19
|
+
if (field.type === "file") return /* @__PURE__ */ jsx(FileField, {
|
|
20
|
+
field,
|
|
21
|
+
hints,
|
|
22
|
+
resource: resource ?? "",
|
|
23
|
+
value,
|
|
24
|
+
locked: locked || !resource,
|
|
25
|
+
invalid,
|
|
26
|
+
onChange
|
|
27
|
+
});
|
|
18
28
|
if (field.sensitive) return /* @__PURE__ */ jsx(SensitiveField, {
|
|
19
29
|
field,
|
|
20
30
|
hints,
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
import { Button } from "../primitives/button.js";
|
|
3
|
+
import { useClient } from "../data/api-provider.js";
|
|
4
|
+
import { humanize } from "../live/naming.js";
|
|
5
|
+
import { acceptOf, fileShown, formatSize, isPreviewable } from "../live/file-values.js";
|
|
6
|
+
import { ErrorBanner } from "./error-banner.js";
|
|
7
|
+
import { useRef, useState } from "react";
|
|
8
|
+
import { FileText, Paperclip, Upload } from "lucide-react";
|
|
9
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
10
|
+
import { useMutation } from "@tanstack/react-query";
|
|
11
|
+
//#region src/app/file-field.tsx
|
|
12
|
+
const Thumbnail = ({ url, type }) => url && isPreviewable(type) ? /* @__PURE__ */ jsx("img", {
|
|
13
|
+
src: url,
|
|
14
|
+
alt: "",
|
|
15
|
+
className: "size-10 shrink-0 rounded border border-border object-cover"
|
|
16
|
+
}) : /* @__PURE__ */ jsx("span", {
|
|
17
|
+
className: "flex size-10 shrink-0 items-center justify-center rounded border border-border bg-muted text-muted-foreground",
|
|
18
|
+
children: /* @__PURE__ */ jsx(FileText, { className: "size-4" })
|
|
19
|
+
});
|
|
20
|
+
/** The file as it is: thumbnail or icon, name (a link while it has a URL), type and size, and what the server corrected. */
|
|
21
|
+
const FileSummary = ({ value }) => {
|
|
22
|
+
const shown = fileShown(value);
|
|
23
|
+
if (!shown) return /* @__PURE__ */ jsx("div", {
|
|
24
|
+
className: "py-1.5 text-[13px] text-muted-foreground",
|
|
25
|
+
children: "—"
|
|
26
|
+
});
|
|
27
|
+
if ("missing" in shown) return /* @__PURE__ */ jsx("div", {
|
|
28
|
+
className: "py-1.5 text-[13px] text-danger-text",
|
|
29
|
+
children: "The file is missing from storage"
|
|
30
|
+
});
|
|
31
|
+
const url = "url" in shown ? shown.url : void 0;
|
|
32
|
+
const corrected = "corrected" in shown ? shown.corrected : void 0;
|
|
33
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
34
|
+
className: "flex min-w-0 items-center gap-3 py-1",
|
|
35
|
+
children: [/* @__PURE__ */ jsx(Thumbnail, {
|
|
36
|
+
url,
|
|
37
|
+
type: shown.type
|
|
38
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
39
|
+
className: "min-w-0 text-[13px]",
|
|
40
|
+
children: [url && !url.startsWith("blob:") ? /* @__PURE__ */ jsx("a", {
|
|
41
|
+
href: url,
|
|
42
|
+
target: "_blank",
|
|
43
|
+
rel: "noreferrer",
|
|
44
|
+
className: "block truncate font-medium text-primary-text hover:underline",
|
|
45
|
+
children: shown.name
|
|
46
|
+
}) : /* @__PURE__ */ jsx("span", {
|
|
47
|
+
className: "block truncate font-medium",
|
|
48
|
+
children: shown.name
|
|
49
|
+
}), /* @__PURE__ */ jsxs("span", {
|
|
50
|
+
className: "block truncate text-xs text-muted-foreground",
|
|
51
|
+
children: [
|
|
52
|
+
shown.type,
|
|
53
|
+
shown.size !== void 0 && ` · ${formatSize(shown.size)}`,
|
|
54
|
+
corrected && ` · sent as ${corrected.from}`
|
|
55
|
+
]
|
|
56
|
+
})]
|
|
57
|
+
})]
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* A file field: drop a file or choose one, and it uploads at once with progress; Save then stores it with the record.
|
|
62
|
+
* The type shown is the one the server detected, so a `photo.png` that is a JPEG says so.
|
|
63
|
+
*/
|
|
64
|
+
const FileField = ({ field, hints, resource, value, locked, invalid, onChange }) => {
|
|
65
|
+
const client = useClient();
|
|
66
|
+
const input = useRef(null);
|
|
67
|
+
const [progress, setProgress] = useState();
|
|
68
|
+
const [dragging, setDragging] = useState(false);
|
|
69
|
+
const label = hints?.label ?? humanize(field.name);
|
|
70
|
+
const upload = useMutation({
|
|
71
|
+
mutationFn: async (file) => {
|
|
72
|
+
setProgress(0);
|
|
73
|
+
const uploaded = await client.upload(resource, field.name, file, { onProgress: (sent, total) => setProgress(total > 0 ? sent / total : 0) });
|
|
74
|
+
return {
|
|
75
|
+
uploaded,
|
|
76
|
+
...isPreviewable(uploaded.file.type) && { preview: URL.createObjectURL(file) }
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
onSuccess: (draft) => onChange(draft),
|
|
80
|
+
onSettled: () => setProgress(void 0)
|
|
81
|
+
});
|
|
82
|
+
if (locked || field.readOnly) return /* @__PURE__ */ jsx(FileSummary, { value });
|
|
83
|
+
const choose = (files) => {
|
|
84
|
+
const file = files?.[0];
|
|
85
|
+
if (file) upload.mutate(file);
|
|
86
|
+
};
|
|
87
|
+
const drop = (event) => {
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
setDragging(false);
|
|
90
|
+
choose(event.dataTransfer.files);
|
|
91
|
+
};
|
|
92
|
+
const picker = /* @__PURE__ */ jsx("input", {
|
|
93
|
+
ref: input,
|
|
94
|
+
type: "file",
|
|
95
|
+
hidden: true,
|
|
96
|
+
accept: acceptOf(field),
|
|
97
|
+
"aria-label": `Choose a file for ${label}`,
|
|
98
|
+
onChange: (event) => choose(event.target.files)
|
|
99
|
+
});
|
|
100
|
+
const error = upload.error && /* @__PURE__ */ jsx("div", {
|
|
101
|
+
className: "mt-2",
|
|
102
|
+
children: /* @__PURE__ */ jsx(ErrorBanner, {
|
|
103
|
+
title: "Not uploaded",
|
|
104
|
+
error: upload.error
|
|
105
|
+
})
|
|
106
|
+
});
|
|
107
|
+
if (upload.isPending) return /* @__PURE__ */ jsxs("div", {
|
|
108
|
+
className: "py-1.5",
|
|
109
|
+
role: "status",
|
|
110
|
+
"aria-label": `Uploading ${label}`,
|
|
111
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
112
|
+
className: "h-1.5 overflow-hidden rounded-full bg-muted",
|
|
113
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
114
|
+
className: "h-full rounded-full bg-primary transition-[width]",
|
|
115
|
+
style: { width: `${Math.round((progress ?? 0) * 100)}%` }
|
|
116
|
+
})
|
|
117
|
+
}), /* @__PURE__ */ jsxs("span", {
|
|
118
|
+
className: "text-xs text-muted-foreground",
|
|
119
|
+
children: [
|
|
120
|
+
"Uploading… ",
|
|
121
|
+
Math.round((progress ?? 0) * 100),
|
|
122
|
+
"%"
|
|
123
|
+
]
|
|
124
|
+
})]
|
|
125
|
+
});
|
|
126
|
+
if (fileShown(value)) return /* @__PURE__ */ jsxs("div", { children: [
|
|
127
|
+
/* @__PURE__ */ jsxs("div", {
|
|
128
|
+
className: "flex items-center gap-2",
|
|
129
|
+
children: [
|
|
130
|
+
/* @__PURE__ */ jsx("div", {
|
|
131
|
+
className: "min-w-0 flex-1",
|
|
132
|
+
children: /* @__PURE__ */ jsx(FileSummary, { value })
|
|
133
|
+
}),
|
|
134
|
+
/* @__PURE__ */ jsx(Button, {
|
|
135
|
+
size: "sm",
|
|
136
|
+
onClick: () => input.current?.click(),
|
|
137
|
+
children: "Replace"
|
|
138
|
+
}),
|
|
139
|
+
field.nullable && /* @__PURE__ */ jsx(Button, {
|
|
140
|
+
size: "sm",
|
|
141
|
+
variant: "ghost",
|
|
142
|
+
onClick: () => onChange(null),
|
|
143
|
+
children: "Remove"
|
|
144
|
+
})
|
|
145
|
+
]
|
|
146
|
+
}),
|
|
147
|
+
picker,
|
|
148
|
+
error
|
|
149
|
+
] });
|
|
150
|
+
return /* @__PURE__ */ jsxs("div", { children: [
|
|
151
|
+
/* @__PURE__ */ jsxs("div", {
|
|
152
|
+
onDragOver: (event) => {
|
|
153
|
+
event.preventDefault();
|
|
154
|
+
setDragging(true);
|
|
155
|
+
},
|
|
156
|
+
onDragLeave: () => setDragging(false),
|
|
157
|
+
onDrop: drop,
|
|
158
|
+
className: cn("flex items-center gap-3 rounded-md border border-dashed px-3 py-2.5 text-[13px] text-muted-foreground", dragging ? "border-primary bg-primary/5" : invalid ? "border-danger" : "border-border-strong"),
|
|
159
|
+
children: [
|
|
160
|
+
/* @__PURE__ */ jsx(Upload, { className: "size-4 shrink-0" }),
|
|
161
|
+
/* @__PURE__ */ jsx("span", {
|
|
162
|
+
className: "flex-1",
|
|
163
|
+
children: "Drop a file here or"
|
|
164
|
+
}),
|
|
165
|
+
/* @__PURE__ */ jsxs(Button, {
|
|
166
|
+
size: "sm",
|
|
167
|
+
onClick: () => input.current?.click(),
|
|
168
|
+
children: [/* @__PURE__ */ jsx(Paperclip, { className: "size-3.5" }), "Choose a file"]
|
|
169
|
+
})
|
|
170
|
+
]
|
|
171
|
+
}),
|
|
172
|
+
picker,
|
|
173
|
+
error
|
|
174
|
+
] });
|
|
175
|
+
};
|
|
176
|
+
//#endregion
|
|
177
|
+
export { FileField };
|
|
@@ -20,6 +20,7 @@ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
|
20
20
|
/** The literal defaults of new records, or the record's values when editing; relations as `{ key, label }` once chosen. */
|
|
21
21
|
const startingDraft = (model, fields, record) => Object.fromEntries(fields.map((name) => {
|
|
22
22
|
const field = model.fields[name];
|
|
23
|
+
if (field.type === "file") return [name, record?.record[name] ?? null];
|
|
23
24
|
if (record) return [name, field.type === "boolean" ? Boolean(record.record[name]) : toDraft(record.record[name])];
|
|
24
25
|
const fallback = field.default && "value" in field.default ? field.default.value : void 0;
|
|
25
26
|
return [name, field.type === "boolean" ? Boolean(fallback) : toDraft(fallback)];
|
|
@@ -99,6 +100,7 @@ const ModalFormDialog = ({ model, record, mode, label, title, fields, values, on
|
|
|
99
100
|
view: views[target.name]
|
|
100
101
|
} : void 0,
|
|
101
102
|
invalid: Boolean(errors[name]),
|
|
103
|
+
resource: model.name,
|
|
102
104
|
onChange: (next) => {
|
|
103
105
|
setErrors(({ [name]: _, ...rest }) => rest);
|
|
104
106
|
setDraft((current) => ({
|
|
@@ -3,6 +3,7 @@ import { Badge } from "../../primitives/badge.js";
|
|
|
3
3
|
import { keys } from "../../data/query-keys.js";
|
|
4
4
|
import { useClient } from "../../data/api-provider.js";
|
|
5
5
|
import { Link } from "../router.js";
|
|
6
|
+
import { fileShown } from "../../live/file-values.js";
|
|
6
7
|
import { displayValue } from "../../live/display-value.js";
|
|
7
8
|
import { useAdminMeta } from "../meta-gate.js";
|
|
8
9
|
import { toneFor } from "../../live/columns-from-model.js";
|
|
@@ -44,6 +45,14 @@ const FieldValue = ({ model, field, value, format, relationLabel }) => {
|
|
|
44
45
|
value,
|
|
45
46
|
known: relationLabel
|
|
46
47
|
});
|
|
48
|
+
const file = field.type === "file" ? fileShown(value) : void 0;
|
|
49
|
+
if (file && "url" in file && file.url) return /* @__PURE__ */ jsx("a", {
|
|
50
|
+
href: file.url,
|
|
51
|
+
target: "_blank",
|
|
52
|
+
rel: "noreferrer",
|
|
53
|
+
className: "font-medium text-primary-text hover:underline",
|
|
54
|
+
children: file.name
|
|
55
|
+
});
|
|
47
56
|
const text = displayValue(field, value, hints);
|
|
48
57
|
if (field.type === "enum" || hints.format === "badge") return /* @__PURE__ */ jsx(Badge, {
|
|
49
58
|
tone: toneFor(String(value)),
|
package/dist/app/record-page.js
CHANGED
|
@@ -151,6 +151,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
151
151
|
locked: !canUpdate || access === "read",
|
|
152
152
|
invalid: Boolean(editor.errors[name]),
|
|
153
153
|
reveal: field.sensitive ? () => client.reveal(model.name, recordKey, name) : void 0,
|
|
154
|
+
resource: model.name,
|
|
154
155
|
onChange: (next) => editor.change(name, next)
|
|
155
156
|
})
|
|
156
157
|
}, field.sensitive ? `${name}:${stored.etag}` : name);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { keys } from "../data/query-keys.js";
|
|
2
2
|
import { useClient } from "../data/api-provider.js";
|
|
3
|
+
import { displayValue } from "../live/display-value.js";
|
|
3
4
|
import { differs, toDraft, toPatchValue } from "../live/field-values.js";
|
|
4
5
|
import { useLiveSave } from "./use-live-save.js";
|
|
5
6
|
import { readDraft, writeDraft } from "./draft-storage.js";
|
|
@@ -48,7 +49,7 @@ const useRecordEditor = ({ model, stored, recordKey, mode, subject, draftKey, li
|
|
|
48
49
|
lines?.order,
|
|
49
50
|
stored.etag
|
|
50
51
|
]);
|
|
51
|
-
const value = (name) => name in draft ? draft[name] : model.fields[name].type
|
|
52
|
+
const value = (name) => name in draft ? draft[name] : ["boolean", "file"].includes(model.fields[name].type) ? stored.record[name] : toDraft(stored.record[name]);
|
|
52
53
|
const change = (name, next) => {
|
|
53
54
|
setErrors((current) => without(current, name));
|
|
54
55
|
setDraft((current) => differs(model.fields[name], next, stored.record[name]) ? {
|
|
@@ -65,7 +66,7 @@ const useRecordEditor = ({ model, stored, recordKey, mode, subject, draftKey, li
|
|
|
65
66
|
const field = model.fields[name];
|
|
66
67
|
if (!differs(field, mine, latest.record[name])) continue;
|
|
67
68
|
keep[name] = mine;
|
|
68
|
-
if (toDraft(latest.record[name]) !== toDraft(stored.record[name])) theirs[name] = toDraft(latest.record[name]);
|
|
69
|
+
if (toDraft(latest.record[name]) !== toDraft(stored.record[name])) theirs[name] = field.type === "file" ? displayValue(field, latest.record[name]) : toDraft(latest.record[name]);
|
|
69
70
|
}
|
|
70
71
|
setDraft(keep);
|
|
71
72
|
setConflicts(theirs);
|
|
@@ -7,7 +7,7 @@ import { useEffect, useState } from "react";
|
|
|
7
7
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
8
|
//#region src/app-shell/app-shell.tsx
|
|
9
9
|
const drawerId = "app-drawer";
|
|
10
|
-
const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, actions, rightPanel, forceExpanded, profileMenuOpen, navGroups, userMenu, onNavigate, onSignOut, search, className, children }) => {
|
|
10
|
+
const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, actions, rightPanel, forceExpanded, profileMenuOpen, navGroups, userMenu, onNavigate, onSignOut, organizations, search, className, children }) => {
|
|
11
11
|
const desktop = useIsDesktop();
|
|
12
12
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
13
13
|
useEffect(() => {
|
|
@@ -23,7 +23,8 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, action
|
|
|
23
23
|
groups: navGroups,
|
|
24
24
|
userMenu,
|
|
25
25
|
onNavigate,
|
|
26
|
-
onSignOut
|
|
26
|
+
onSignOut,
|
|
27
|
+
organizations
|
|
27
28
|
});
|
|
28
29
|
return /* @__PURE__ */ jsxs("div", {
|
|
29
30
|
className: cn("flex h-full min-h-0 w-full overflow-hidden bg-background", className),
|
|
@@ -2,12 +2,12 @@ import { cn } from "../lib/cn.js";
|
|
|
2
2
|
import { Avatar } from "../primitives/avatar.js";
|
|
3
3
|
import { Badge } from "../primitives/badge.js";
|
|
4
4
|
import { useEffect, useRef, useState } from "react";
|
|
5
|
-
import { ChevronsUpDown, LogOut } from "lucide-react";
|
|
5
|
+
import { Check, ChevronsUpDown, LogOut } from "lucide-react";
|
|
6
6
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
7
7
|
//#region src/app-shell/profile-menu.tsx
|
|
8
8
|
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
9
|
/** 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 }) => {
|
|
10
|
+
const ProfileMenu = ({ user, compact, open: controlled, items = [], onNavigate, onSignOut, organizations }) => {
|
|
11
11
|
const [local, setLocal] = useState(false);
|
|
12
12
|
const open = controlled ?? local;
|
|
13
13
|
const root = useRef(null);
|
|
@@ -77,6 +77,31 @@ const ProfileMenu = ({ user, compact, open: controlled, items = [], onNavigate,
|
|
|
77
77
|
children: user.email
|
|
78
78
|
})]
|
|
79
79
|
}),
|
|
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))]
|
|
104
|
+
}),
|
|
80
105
|
items.length > 0 && /* @__PURE__ */ jsx("div", {
|
|
81
106
|
className: cn(onSignOut && "border-b"),
|
|
82
107
|
children: items.map((item) => {
|
|
@@ -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 }) => {
|
|
10
|
+
const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenuOpen, groups = navGroups, userMenu, onNavigate, onSignOut, organizations }) => {
|
|
11
11
|
const expanding = mode === "icon-expand";
|
|
12
12
|
const expanded = expanding && forceExpanded;
|
|
13
13
|
const inline = isTextMode(mode) || expanding;
|
|
@@ -53,7 +53,8 @@ const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenu
|
|
|
53
53
|
open: profileMenuOpen,
|
|
54
54
|
items: userMenu,
|
|
55
55
|
onNavigate,
|
|
56
|
-
onSignOut
|
|
56
|
+
onSignOut,
|
|
57
|
+
organizations
|
|
57
58
|
})
|
|
58
59
|
})
|
|
59
60
|
]
|
package/dist/auth/auth-gate.js
CHANGED
|
@@ -14,6 +14,8 @@ import { SignInPage } from "./sign-in-page.js";
|
|
|
14
14
|
import { readStaffLink, withoutStaffLink } from "./staff-link.js";
|
|
15
15
|
import { StaffSignInPage } from "./staff-sign-in-page.js";
|
|
16
16
|
import { TwoFactorPage } from "./two-factor-page.js";
|
|
17
|
+
import { readInvitationLink, withoutInvitationLink } from "../organizations/invitation-link.js";
|
|
18
|
+
import { InvitationPage } from "../organizations/invitation-page.js";
|
|
17
19
|
import { useCallback, useEffect, useState } from "react";
|
|
18
20
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
19
21
|
//#region src/auth/auth-gate.tsx
|
|
@@ -29,6 +31,12 @@ const firstPage = () => {
|
|
|
29
31
|
kind: "staff-sign-in",
|
|
30
32
|
link: staff
|
|
31
33
|
};
|
|
34
|
+
const invitation = readInvitationLink(href);
|
|
35
|
+
if (invitation) return {
|
|
36
|
+
kind: "invitation",
|
|
37
|
+
token: invitation.token,
|
|
38
|
+
signingIn: false
|
|
39
|
+
};
|
|
32
40
|
const provider = readSignInLink(href);
|
|
33
41
|
if (provider) return {
|
|
34
42
|
kind: "provider-sign-in",
|
|
@@ -43,9 +51,11 @@ const firstPage = () => {
|
|
|
43
51
|
const forgetResetLink = () => window.history.replaceState(window.history.state, "", withoutResetLink(window.location.href));
|
|
44
52
|
const forgetStaffLink = () => window.history.replaceState(window.history.state, "", withoutStaffLink(window.location.href));
|
|
45
53
|
const forgetSignInLink = () => window.history.replaceState(window.history.state, "", withoutSignInLink(window.location.href));
|
|
54
|
+
const forgetInvitationLink = () => window.history.replaceState(window.history.state, "", withoutInvitationLink(window.location.href));
|
|
46
55
|
/**
|
|
47
56
|
* Shows the app when the user is signed in (or the server has no login), and the right page otherwise: the sign-in
|
|
48
|
-
* steps, or the page an emailed reset link or a staff link opened.
|
|
57
|
+
* steps, or the page an emailed reset link, an invitation link or a staff link opened. An invited person with an
|
|
58
|
+
* account signs in first and comes back to the invitation.
|
|
49
59
|
*/
|
|
50
60
|
const AuthGate = ({ workspace, children }) => {
|
|
51
61
|
const { state, recheck, passwordReset, signOut, signInProviders } = useAuth();
|
|
@@ -108,6 +118,17 @@ const AuthGate = ({ workspace, children }) => {
|
|
|
108
118
|
setPage({ kind: "sign-in" });
|
|
109
119
|
}
|
|
110
120
|
});
|
|
121
|
+
if (page.kind === "invitation" && (state.kind === "signed-in" || state.kind === "signed-out" && !page.signingIn)) return /* @__PURE__ */ jsx(InvitationPage, {
|
|
122
|
+
token: page.token,
|
|
123
|
+
onDone: () => {
|
|
124
|
+
forgetInvitationLink();
|
|
125
|
+
setPage({ kind: "sign-in" });
|
|
126
|
+
},
|
|
127
|
+
onSignIn: () => setPage({
|
|
128
|
+
...page,
|
|
129
|
+
signingIn: true
|
|
130
|
+
})
|
|
131
|
+
});
|
|
111
132
|
if (state.kind === "signed-in") return children;
|
|
112
133
|
if (state.kind === "two-factor") return /* @__PURE__ */ jsx(TwoFactorPage, {
|
|
113
134
|
methods: state.methods,
|
|
@@ -124,11 +145,12 @@ const AuthGate = ({ workspace, children }) => {
|
|
|
124
145
|
email: page.email,
|
|
125
146
|
onBack: () => setPage({ kind: "sign-in" })
|
|
126
147
|
});
|
|
127
|
-
const
|
|
128
|
-
const
|
|
148
|
+
const signIn = page.kind === "sign-in" ? page : { kind: "sign-in" };
|
|
149
|
+
const providerError = signIn.providerError && providerMessage(signIn.providerError, signInProviders.length === 1 ? signInProviders[0].name : "the provider");
|
|
150
|
+
const error = signIn.error ?? providerError;
|
|
129
151
|
return /* @__PURE__ */ jsx(SignInPage, {
|
|
130
152
|
workspace,
|
|
131
|
-
notice:
|
|
153
|
+
notice: signIn.notice,
|
|
132
154
|
error,
|
|
133
155
|
onForgotPassword: passwordReset ? () => setPage({ kind: "forgot-password" }) : void 0,
|
|
134
156
|
onCodeSent: (email) => setPage({
|
|
@@ -17,6 +17,7 @@ const AuthProvider = ({ session: given, children }) => {
|
|
|
17
17
|
const [socialProviders, setSocialProviders] = useState([]);
|
|
18
18
|
const [platformSignIn, setPlatformSignIn] = useState();
|
|
19
19
|
const [staffSignIn, setStaffSignIn] = useState();
|
|
20
|
+
const [organizations, setOrganizations] = useState();
|
|
20
21
|
const markSignedOut = useCallback(() => setState((current) => current.kind === "signed-in" ? { kind: "signed-out" } : current), []);
|
|
21
22
|
const signedOut = useRef(markSignedOut);
|
|
22
23
|
signedOut.current = markSignedOut;
|
|
@@ -45,6 +46,7 @@ const AuthProvider = ({ session: given, children }) => {
|
|
|
45
46
|
setSocialProviders(status.socialProviders);
|
|
46
47
|
setPlatformSignIn(status.platformSignIn);
|
|
47
48
|
setStaffSignIn(status.staffSignIn);
|
|
49
|
+
setOrganizations(status.organizations);
|
|
48
50
|
if (status.needsAdmin) return setState({ kind: "needs-admin" });
|
|
49
51
|
const user = await session.session();
|
|
50
52
|
return user ? enter(user) : setState({ kind: "signed-out" });
|
|
@@ -65,6 +67,7 @@ const AuthProvider = ({ session: given, children }) => {
|
|
|
65
67
|
name: providerName(id, platformSignIn)
|
|
66
68
|
})),
|
|
67
69
|
staffSignIn,
|
|
70
|
+
...organizations && { organizations },
|
|
68
71
|
markSignedOut,
|
|
69
72
|
recheck,
|
|
70
73
|
signIn: async (email, password) => finish(await session.signIn(email, password)),
|
|
@@ -94,6 +97,7 @@ const AuthProvider = ({ session: given, children }) => {
|
|
|
94
97
|
socialProviders,
|
|
95
98
|
platformSignIn,
|
|
96
99
|
staffSignIn,
|
|
100
|
+
organizations,
|
|
97
101
|
markSignedOut,
|
|
98
102
|
recheck,
|
|
99
103
|
finish,
|
|
@@ -31,6 +31,8 @@ export declare const useClient: () => {
|
|
|
31
31
|
position: number;
|
|
32
32
|
}) => Promise<import("@protobase/client").ListPage<import("@protobase/client").RecordOf<R>>>;
|
|
33
33
|
meta: (etag?: string) => Promise<import("@protobase/client").MetaResult>;
|
|
34
|
+
invoke: <T = unknown>(name: string, { method, body, path, query, headers }?: import("@protobase/client").InvokeOptions) => Promise<T>;
|
|
35
|
+
upload: (resource: import("@protobase/client").ResourceRef, field: string, file: Blob, { onProgress, signal }?: import("@protobase/client").UploadOptions) => Promise<import("@protobase/client").UploadResult>;
|
|
34
36
|
};
|
|
35
37
|
export type ApiProviderProps = {
|
|
36
38
|
/** Defaults to `/api/v1` on the current origin. */
|
package/dist/data-grid/cells.js
CHANGED
|
@@ -4,8 +4,9 @@ import { Badge } from "../primitives/badge.js";
|
|
|
4
4
|
import { formatMoney } from "../format/money.js";
|
|
5
5
|
import { formatInt, formatPercent, formatSigned } from "../format/number.js";
|
|
6
6
|
import { formatDate, formatDateTime } from "../format/date.js";
|
|
7
|
+
import { fileShown, isPreviewable } from "../live/file-values.js";
|
|
7
8
|
import { userById } from "../mocks/users.js";
|
|
8
|
-
import { Check } from "lucide-react";
|
|
9
|
+
import { Check, Paperclip } from "lucide-react";
|
|
9
10
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
10
11
|
import { enumLabel } from "@protobase/schema";
|
|
11
12
|
//#region src/data-grid/cells.tsx
|
|
@@ -64,6 +65,26 @@ const renderCell = (spec, value) => {
|
|
|
64
65
|
strokeWidth: 3
|
|
65
66
|
})
|
|
66
67
|
}) : muted("No");
|
|
68
|
+
case "file": {
|
|
69
|
+
const file = fileShown(value);
|
|
70
|
+
if (!file) return muted("—");
|
|
71
|
+
if ("missing" in file) return /* @__PURE__ */ jsx("span", {
|
|
72
|
+
className: "truncate text-danger-text",
|
|
73
|
+
children: file.name
|
|
74
|
+
});
|
|
75
|
+
const url = "url" in file ? file.url : void 0;
|
|
76
|
+
return /* @__PURE__ */ jsxs("span", {
|
|
77
|
+
className: "inline-flex min-w-0 items-center gap-2",
|
|
78
|
+
children: [url && isPreviewable(file.type) ? /* @__PURE__ */ jsx("img", {
|
|
79
|
+
src: url,
|
|
80
|
+
alt: "",
|
|
81
|
+
className: "size-5 shrink-0 rounded-sm object-cover"
|
|
82
|
+
}) : /* @__PURE__ */ jsx(Paperclip, { className: "size-3.5 shrink-0 text-muted-foreground" }), /* @__PURE__ */ jsx("span", {
|
|
83
|
+
className: "truncate",
|
|
84
|
+
children: file.name
|
|
85
|
+
})]
|
|
86
|
+
});
|
|
87
|
+
}
|
|
67
88
|
case "user": {
|
|
68
89
|
const user = userById(String(value));
|
|
69
90
|
return /* @__PURE__ */ jsxs("span", {
|
|
@@ -32,7 +32,8 @@ const widths = {
|
|
|
32
32
|
number: 100,
|
|
33
33
|
signed: 110,
|
|
34
34
|
percent: 110,
|
|
35
|
-
user: 130
|
|
35
|
+
user: 130,
|
|
36
|
+
file: 180
|
|
36
37
|
};
|
|
37
38
|
/** Room for a header at 12px plus cell padding, the sort arrow and the column menu button. */
|
|
38
39
|
const headerWidth = (header, sortable) => Math.ceil(header.length * 7.2) + 24 + (sortable ? 16 : 0) + 28;
|
|
@@ -48,6 +49,7 @@ const idLike = /^(number|sku|reference|code)$/;
|
|
|
48
49
|
const kindOf = (field, view, index) => {
|
|
49
50
|
switch (field.type) {
|
|
50
51
|
case "relation": return "relation";
|
|
52
|
+
case "file": return "file";
|
|
51
53
|
case "enum": return "status";
|
|
52
54
|
case "boolean": return "boolean";
|
|
53
55
|
case "timestamp": return "datetime";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { formatDate, formatDateTime } from "../format/date.js";
|
|
2
|
+
import { fileShown } from "./file-values.js";
|
|
2
3
|
import { enumLabel } from "@protobase/schema";
|
|
3
4
|
//#region src/live/display-value.ts
|
|
4
5
|
/** A read-only rendering of a stored value, following the view's prefix, decimals, format and value label hints. */
|
|
@@ -6,6 +7,7 @@ const displayValue = (field, value, hints = {}) => {
|
|
|
6
7
|
if (value === null || value === void 0 || value === "") return "—";
|
|
7
8
|
switch (field.type) {
|
|
8
9
|
case "boolean": return value ? "Yes" : "No";
|
|
10
|
+
case "file": return fileShown(value)?.name ?? "—";
|
|
9
11
|
case "enum": return enumLabel(String(value), hints.valueLabels);
|
|
10
12
|
case "timestamp": return formatDateTime(Date.parse(String(value)));
|
|
11
13
|
case "date": return formatDate(Date.parse(String(value)));
|