@protobase/ui 0.6.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 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 accountPage = path === "/-/account" ? "Sign-in & security" : path === "/-/sign-in-policy" ? "Sign-in policy" : void 0;
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: [...userMenuFromMeta(meta, basePath, route.resource), ...accountMenuItems(basePath, path, admin)],
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,
@@ -99,6 +99,7 @@ const LoadedCreate = ({ resource }) => {
99
99
  view: meta.views[target.name]
100
100
  } : void 0,
101
101
  invalid: Boolean(errors[name]),
102
+ resource: model.name,
102
103
  onChange: (next) => change(name, next)
103
104
  })
104
105
  };
@@ -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
  }));
@@ -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)),
@@ -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 === "boolean" ? stored.record[name] : toDraft(stored.record[name]);
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
  ]
@@ -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 providerError = page.providerError && providerMessage(page.providerError, signInProviders.length === 1 ? signInProviders[0].name : "the provider");
128
- const error = page.error ?? providerError;
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: page.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. */
@@ -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)));