@protobase/ui 0.7.0 → 0.8.1

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.
@@ -0,0 +1,204 @@
1
+ import { Input } from "../primitives/input.js";
2
+ import { PageHeader } from "../app-shell/page-header.js";
3
+ import { useAuth } from "../auth/auth-provider.js";
4
+ import { Button } from "../primitives/button.js";
5
+ import { Dialog } from "../primitives/dialog.js";
6
+ import { AccountSection, SectionError } from "../account/account-section.js";
7
+ import { managesOrganization, organizationRoleLabels } from "./organization-paths.js";
8
+ import { useAction } from "./use-action.js";
9
+ import { reopenIn } from "./use-organizations.js";
10
+ import { useEffect, useState } from "react";
11
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
12
+ //#region src/organizations/organization-page.tsx
13
+ const Details = ({ organization, editable, onSaved }) => {
14
+ const { session } = useAuth();
15
+ const [name, setName] = useState(organization.name);
16
+ const [slug, setSlug] = useState(organization.slug);
17
+ const { busy, error, run } = useAction();
18
+ const changed = name.trim() !== organization.name || slug !== organization.slug;
19
+ return /* @__PURE__ */ jsxs(AccountSection, {
20
+ title: "Details",
21
+ description: editable ? "The name people see, and the slug that names it in links." : "Owners and admins can change these.",
22
+ children: [/* @__PURE__ */ jsx(SectionError, { message: error }), /* @__PURE__ */ jsxs("form", {
23
+ className: "flex flex-col gap-2 sm:flex-row",
24
+ onSubmit: (event) => {
25
+ event.preventDefault();
26
+ run(async () => {
27
+ await session.organizations.rename(organization.id, {
28
+ name: name.trim(),
29
+ slug
30
+ });
31
+ onSaved();
32
+ });
33
+ },
34
+ children: [
35
+ /* @__PURE__ */ jsx(Input, {
36
+ "aria-label": "Name",
37
+ value: name,
38
+ disabled: !editable,
39
+ onChange: (event) => setName(event.target.value),
40
+ wrapperClassName: "flex-1"
41
+ }),
42
+ /* @__PURE__ */ jsx(Input, {
43
+ "aria-label": "Slug",
44
+ value: slug,
45
+ disabled: !editable,
46
+ onChange: (event) => setSlug(event.target.value),
47
+ wrapperClassName: "sm:w-48"
48
+ }),
49
+ editable && /* @__PURE__ */ jsx(Button, {
50
+ type: "submit",
51
+ variant: "primary",
52
+ loading: busy,
53
+ disabled: !changed || !name.trim(),
54
+ children: "Save"
55
+ })
56
+ ]
57
+ })]
58
+ });
59
+ };
60
+ const HandOver = ({ organization, memberId }) => {
61
+ const { session } = useAuth();
62
+ const [members, setMembers] = useState([]);
63
+ const [target, setTarget] = useState("");
64
+ const { busy, error, run } = useAction();
65
+ useEffect(() => {
66
+ session.organizations.members(organization.id).then((list) => setMembers(list.filter((member) => member.id !== memberId)), () => setMembers([]));
67
+ }, [
68
+ session,
69
+ organization.id,
70
+ memberId
71
+ ]);
72
+ return /* @__PURE__ */ jsxs(AccountSection, {
73
+ title: "Hand over",
74
+ description: "Make another member the owner. You stay as an admin.",
75
+ children: [/* @__PURE__ */ jsx(SectionError, { message: error }), /* @__PURE__ */ jsxs("div", {
76
+ className: "flex flex-col gap-2 sm:flex-row",
77
+ children: [/* @__PURE__ */ jsxs("select", {
78
+ "aria-label": "New owner",
79
+ value: target,
80
+ onChange: (event) => setTarget(event.target.value),
81
+ className: "h-8 flex-1 rounded-md border border-border-strong bg-background px-2 text-[13px]",
82
+ children: [/* @__PURE__ */ jsx("option", {
83
+ value: "",
84
+ children: "Choose a member"
85
+ }), members.map((member) => /* @__PURE__ */ jsxs("option", {
86
+ value: member.id,
87
+ children: [
88
+ member.name,
89
+ " (",
90
+ member.email,
91
+ ")"
92
+ ]
93
+ }, member.id))]
94
+ }), /* @__PURE__ */ jsx(Button, {
95
+ loading: busy,
96
+ disabled: !target,
97
+ onClick: () => void run(async () => {
98
+ await session.organizations.transferOwnership(target);
99
+ window.location.reload();
100
+ }),
101
+ children: "Hand over"
102
+ })]
103
+ })]
104
+ });
105
+ };
106
+ const Delete = ({ organization, basePath }) => {
107
+ const { session } = useAuth();
108
+ const [confirming, setConfirming] = useState(false);
109
+ const [typed, setTyped] = useState("");
110
+ const { busy, error, run } = useAction();
111
+ const remove = () => run(async () => {
112
+ await session.organizations.remove(organization.id);
113
+ reopenIn(basePath);
114
+ });
115
+ return /* @__PURE__ */ jsxs(AccountSection, {
116
+ title: "Delete organization",
117
+ description: "Takes the organization and its members and invitations away. Its records must be deleted first.",
118
+ children: [
119
+ /* @__PURE__ */ jsx(SectionError, { message: confirming ? void 0 : error }),
120
+ /* @__PURE__ */ jsx(Button, {
121
+ variant: "danger",
122
+ onClick: () => setConfirming(true),
123
+ children: "Delete organization"
124
+ }),
125
+ confirming && /* @__PURE__ */ jsxs(Dialog, {
126
+ title: `Delete ${organization.name}?`,
127
+ description: "Type its name to confirm. This cannot be undone.",
128
+ onClose: () => setConfirming(false),
129
+ actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
130
+ onClick: () => setConfirming(false),
131
+ children: "Cancel"
132
+ }), /* @__PURE__ */ jsx(Button, {
133
+ variant: "danger",
134
+ loading: busy,
135
+ disabled: typed !== organization.name,
136
+ onClick: () => void remove(),
137
+ children: "Delete"
138
+ })] }),
139
+ children: [/* @__PURE__ */ jsx(SectionError, { message: error }), /* @__PURE__ */ jsx(Input, {
140
+ "aria-label": "Organization name",
141
+ value: typed,
142
+ onChange: (event) => setTyped(event.target.value)
143
+ })]
144
+ })
145
+ ]
146
+ });
147
+ };
148
+ const Leave = ({ organization, basePath, owner }) => {
149
+ const { session } = useAuth();
150
+ const { busy, error, run } = useAction();
151
+ return /* @__PURE__ */ jsxs(AccountSection, {
152
+ title: "Leave",
153
+ description: owner ? "An organization keeps an owner: hand it over before you leave, unless it has another owner." : "You stop being a member, and lose your roles here.",
154
+ children: [/* @__PURE__ */ jsx(SectionError, { message: error }), /* @__PURE__ */ jsxs(Button, {
155
+ loading: busy,
156
+ onClick: () => void run(async () => {
157
+ await session.organizations.leave(organization.id);
158
+ reopenIn(basePath);
159
+ }),
160
+ children: ["Leave ", organization.name]
161
+ })]
162
+ });
163
+ };
164
+ /** The organization one works in: its details, and handing it over, leaving and deleting it, as one's role allows. */
165
+ const OrganizationPage = ({ current, basePath, onChanged }) => {
166
+ const organization = current?.organization;
167
+ if (!organization) return /* @__PURE__ */ jsx("div", {
168
+ className: "mx-auto w-full max-w-3xl px-4 py-6 md:px-6",
169
+ children: /* @__PURE__ */ jsx(PageHeader, {
170
+ title: "Organization",
171
+ subtitle: "You do not work in an organization now. Choose one under Organizations."
172
+ })
173
+ });
174
+ const owner = current.role === "owner";
175
+ return /* @__PURE__ */ jsxs("div", {
176
+ className: "mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 py-6 md:px-6",
177
+ children: [
178
+ /* @__PURE__ */ jsx(PageHeader, {
179
+ title: organization.name,
180
+ subtitle: current.role ? `You are ${organizationRoleLabels[current.role].toLowerCase()} here.` : "You work here through your global roles, without being a member."
181
+ }),
182
+ /* @__PURE__ */ jsx(Details, {
183
+ organization,
184
+ editable: managesOrganization(current),
185
+ onSaved: onChanged
186
+ }, `${organization.name}/${organization.slug}`),
187
+ owner && current.memberId && /* @__PURE__ */ jsx(HandOver, {
188
+ organization,
189
+ memberId: current.memberId
190
+ }),
191
+ current.role && /* @__PURE__ */ jsx(Leave, {
192
+ organization,
193
+ basePath,
194
+ owner
195
+ }),
196
+ owner && /* @__PURE__ */ jsx(Delete, {
197
+ organization,
198
+ basePath
199
+ })
200
+ ]
201
+ });
202
+ };
203
+ //#endregion
204
+ export { OrganizationPage };
@@ -0,0 +1,49 @@
1
+ import { Building2, Mail, Network, Users } from "lucide-react";
2
+ //#region src/organizations/organization-paths.ts
3
+ const organizationsPath = "/-/organizations";
4
+ const organizationPath = "/-/organization";
5
+ const membersPath = "/-/organization/members";
6
+ const invitationsPath = "/-/organization/invitations";
7
+ const organizationPageTitles = {
8
+ [organizationsPath]: "Organizations",
9
+ [organizationPath]: "Organization",
10
+ [membersPath]: "Members",
11
+ [invitationsPath]: "Invitations"
12
+ };
13
+ const organizationRoleLabels = {
14
+ owner: "Owner",
15
+ admin: "Admin",
16
+ member: "Member"
17
+ };
18
+ /** The label of an app role, as the server names it. */
19
+ const roleLabel = (roles, name) => roles.find((role) => role.name === name)?.label ?? name;
20
+ /** Whether the person manages the current organization: its owners and admins. */
21
+ const managesOrganization = (current) => current?.role === "owner" || current?.role === "admin";
22
+ /**
23
+ * The organization pages in the profile menu: every organization of the person, and for the one they work in its
24
+ * details, and for its owners and admins its members and invitations.
25
+ */
26
+ const organizationMenuItems = (basePath, path, current) => {
27
+ const item = (id, label, icon, target) => ({
28
+ id,
29
+ label,
30
+ icon,
31
+ href: `${basePath}${target}`,
32
+ active: path === target
33
+ });
34
+ return [
35
+ item("organizations", "Organizations", Network, organizationsPath),
36
+ ...current?.organization ? [item("organization", "Organization", Building2, organizationPath)] : [],
37
+ ...managesOrganization(current) ? [item("members", "Members", Users, membersPath), item("invitations", "Invitations", Mail, invitationsPath)] : []
38
+ ];
39
+ };
40
+ /** The app roles someone may give or take away: those they hold, those their roles grant, and every one for a superuser. */
41
+ const grantableRoles = (roles, current) => {
42
+ const held = [...current?.globalRoles ?? [], ...current?.appRoles ?? []];
43
+ const membership = roles.filter((role) => role.membership).map((role) => role.name);
44
+ if (held.includes("admin")) return new Set(membership);
45
+ const granted = held.flatMap((name) => roles.find((role) => role.name === name)?.grants ?? []);
46
+ return new Set([...held, ...granted].filter((name) => membership.includes(name)));
47
+ };
48
+ //#endregion
49
+ export { grantableRoles, invitationsPath, managesOrganization, membersPath, organizationMenuItems, organizationPageTitles, organizationPath, organizationRoleLabels, organizationsPath, roleLabel };
@@ -0,0 +1,196 @@
1
+ import { Badge } from "../primitives/badge.js";
2
+ import { Input } from "../primitives/input.js";
3
+ import { PageHeader } from "../app-shell/page-header.js";
4
+ import { useAuth } from "../auth/auth-provider.js";
5
+ import { Button } from "../primitives/button.js";
6
+ import { AccountSection, SectionError } from "../account/account-section.js";
7
+ import { roleLabel } from "./organization-paths.js";
8
+ import { useAction } from "./use-action.js";
9
+ import { isAdmin } from "../auth/user-to-shell.js";
10
+ import { reopenIn } from "./use-organizations.js";
11
+ import { useEffect, useState } from "react";
12
+ import { Search } from "lucide-react";
13
+ import { jsx, jsxs } from "react/jsx-runtime";
14
+ //#region src/organizations/organizations-page.tsx
15
+ /** A slug from a name: lowercase ASCII words joined by dashes, as the server accepts them. */
16
+ const slugOf = (name) => name.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
17
+ const OrganizationRow = ({ organization, current, busy, onOpen }) => /* @__PURE__ */ jsxs("li", {
18
+ className: "flex items-center gap-3 px-3 py-2.5",
19
+ children: [/* @__PURE__ */ jsxs("span", {
20
+ className: "min-w-0 flex-1",
21
+ children: [/* @__PURE__ */ jsx("span", {
22
+ className: "block truncate text-[13px] font-medium",
23
+ children: organization.name
24
+ }), /* @__PURE__ */ jsx("span", {
25
+ className: "block text-xs text-muted-foreground",
26
+ children: organization.slug
27
+ })]
28
+ }), current ? /* @__PURE__ */ jsx(Badge, {
29
+ tone: "green",
30
+ children: "Current"
31
+ }) : /* @__PURE__ */ jsx(Button, {
32
+ size: "sm",
33
+ loading: busy,
34
+ onClick: onOpen,
35
+ children: "Open"
36
+ })]
37
+ });
38
+ const NewOrganization = ({ basePath }) => {
39
+ const { session } = useAuth();
40
+ const [name, setName] = useState("");
41
+ const [slug, setSlug] = useState();
42
+ const { busy, error, run } = useAction();
43
+ const create = () => run(async () => {
44
+ await session.organizations.create({
45
+ name: name.trim(),
46
+ slug: slug ?? slugOf(name)
47
+ });
48
+ reopenIn(basePath);
49
+ });
50
+ return /* @__PURE__ */ jsxs(AccountSection, {
51
+ title: "New organization",
52
+ description: "You become its owner, with every role, and work in it once it is made.",
53
+ children: [/* @__PURE__ */ jsx(SectionError, { message: error }), /* @__PURE__ */ jsxs("form", {
54
+ className: "flex flex-col gap-2 sm:flex-row",
55
+ onSubmit: (event) => {
56
+ event.preventDefault();
57
+ create();
58
+ },
59
+ children: [
60
+ /* @__PURE__ */ jsx(Input, {
61
+ "aria-label": "Name",
62
+ placeholder: "Name",
63
+ value: name,
64
+ onChange: (event) => setName(event.target.value),
65
+ wrapperClassName: "flex-1"
66
+ }),
67
+ /* @__PURE__ */ jsx(Input, {
68
+ "aria-label": "Slug",
69
+ placeholder: "slug",
70
+ value: slug ?? slugOf(name),
71
+ onChange: (event) => setSlug(event.target.value),
72
+ wrapperClassName: "sm:w-48"
73
+ }),
74
+ /* @__PURE__ */ jsx(Button, {
75
+ type: "submit",
76
+ variant: "primary",
77
+ loading: busy,
78
+ disabled: !name.trim(),
79
+ children: "Create"
80
+ })
81
+ ]
82
+ })]
83
+ });
84
+ };
85
+ const FindOrganization = ({ basePath, currentId }) => {
86
+ const { session } = useAuth();
87
+ const [query, setQuery] = useState("");
88
+ const [found, setFound] = useState([]);
89
+ const { busy, error, run } = useAction();
90
+ useEffect(() => {
91
+ const timer = setTimeout(() => void session.organizations.search(query).then(setFound, () => setFound([])), 200);
92
+ return () => clearTimeout(timer);
93
+ }, [session, query]);
94
+ return /* @__PURE__ */ jsxs(AccountSection, {
95
+ title: "Find an organization",
96
+ description: "Your global roles let you work in any organization, with what those roles allow there. Opening one you are not a member of is written to the audit log.",
97
+ children: [
98
+ /* @__PURE__ */ jsx(SectionError, { message: error }),
99
+ /* @__PURE__ */ jsx(Input, {
100
+ "aria-label": "Find an organization",
101
+ placeholder: "Name or slug",
102
+ value: query,
103
+ onChange: (event) => setQuery(event.target.value),
104
+ leading: /* @__PURE__ */ jsx(Search, { className: "size-3.5" }),
105
+ wrapperClassName: "mb-3"
106
+ }),
107
+ found.length > 0 && /* @__PURE__ */ jsx("ul", {
108
+ className: "divide-y rounded-md border",
109
+ children: found.map((organization) => /* @__PURE__ */ jsx(OrganizationRow, {
110
+ organization,
111
+ current: organization.id === currentId,
112
+ busy,
113
+ onOpen: () => void run(async () => {
114
+ await session.organizations.switchTo(organization.id);
115
+ reopenIn(basePath);
116
+ })
117
+ }, organization.id))
118
+ })
119
+ ]
120
+ });
121
+ };
122
+ const YourInvitations = () => {
123
+ const { session, organizations } = useAuth();
124
+ const [invitations, setInvitations] = useState([]);
125
+ useEffect(() => {
126
+ session.organizations.myInvitations().then(setInvitations, () => setInvitations([]));
127
+ }, [session]);
128
+ if (invitations.length === 0) return null;
129
+ const roles = organizations?.roles ?? [];
130
+ return /* @__PURE__ */ jsx(AccountSection, {
131
+ title: "Invitations for you",
132
+ description: "Open the link in the invitation email to accept one.",
133
+ children: /* @__PURE__ */ jsx("ul", {
134
+ className: "divide-y rounded-md border",
135
+ children: invitations.map((invitation) => /* @__PURE__ */ jsxs("li", {
136
+ className: "px-3 py-2.5 text-[13px]",
137
+ children: [
138
+ /* @__PURE__ */ jsx("span", {
139
+ className: "font-medium",
140
+ children: invitation.organizationName ?? "An organization"
141
+ }),
142
+ ": ",
143
+ invitation.appRoles.map((name) => roleLabel(roles, name)).join(", ") || "no app roles",
144
+ /* @__PURE__ */ jsxs("span", {
145
+ className: "block text-xs text-muted-foreground",
146
+ children: ["Until ", new Date(invitation.expiresAt).toLocaleDateString()]
147
+ })
148
+ ]
149
+ }, invitation.id))
150
+ })
151
+ });
152
+ };
153
+ /**
154
+ * The person's organizations: switching between them, making a new one when the app allows it, and for people with a
155
+ * global role finding any organization. Someone in none sees their invitations here.
156
+ */
157
+ const OrganizationsPage = ({ state, basePath }) => {
158
+ const { session, organizations: settings, state: auth } = useAuth();
159
+ const { busy, error, run } = useAction();
160
+ const user = auth.kind === "signed-in" ? auth.user : void 0;
161
+ const mayCreate = settings?.create === "everyone" || user !== void 0 && isAdmin(user);
162
+ const currentId = state.current?.organization?.id;
163
+ return /* @__PURE__ */ jsxs("div", {
164
+ className: "mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 py-6 md:px-6",
165
+ children: [
166
+ /* @__PURE__ */ jsx(PageHeader, {
167
+ title: "Organizations",
168
+ subtitle: "You work in one organization at a time; what you see follows from your roles there."
169
+ }),
170
+ /* @__PURE__ */ jsxs(AccountSection, {
171
+ title: "Your organizations",
172
+ description: state.organizations.length > 0 ? "The organizations you are a member of." : "You are not a member of an organization yet.",
173
+ children: [/* @__PURE__ */ jsx(SectionError, { message: error ?? state.error }), state.organizations.length > 0 && /* @__PURE__ */ jsx("ul", {
174
+ className: "divide-y rounded-md border",
175
+ children: state.organizations.map((organization) => /* @__PURE__ */ jsx(OrganizationRow, {
176
+ organization,
177
+ current: organization.id === currentId,
178
+ busy,
179
+ onOpen: () => void run(async () => {
180
+ await session.organizations.switchTo(organization.id);
181
+ reopenIn(basePath);
182
+ })
183
+ }, organization.id))
184
+ })]
185
+ }),
186
+ /* @__PURE__ */ jsx(YourInvitations, {}),
187
+ mayCreate && /* @__PURE__ */ jsx(NewOrganization, { basePath }),
188
+ (state.current?.globalRoles.length ?? 0) > 0 && /* @__PURE__ */ jsx(FindOrganization, {
189
+ basePath,
190
+ currentId
191
+ })
192
+ ]
193
+ });
194
+ };
195
+ //#endregion
196
+ export { OrganizationsPage, slugOf };
@@ -0,0 +1,28 @@
1
+ import { authMessage } from "../auth/auth-messages.js";
2
+ import { useState } from "react";
3
+ import { AuthError } from "@protobase/client";
4
+ //#region src/organizations/use-action.ts
5
+ /** Runs one change at a time and keeps the server's refusal, in words, for the form to show. */
6
+ const useAction = () => {
7
+ const [busy, setBusy] = useState(false);
8
+ const [error, setError] = useState();
9
+ const run = async (action) => {
10
+ setBusy(true);
11
+ setError(void 0);
12
+ try {
13
+ await action();
14
+ } catch (failure) {
15
+ if (!(failure instanceof AuthError)) throw failure;
16
+ setError(authMessage(failure));
17
+ }
18
+ setBusy(false);
19
+ };
20
+ return {
21
+ busy,
22
+ error,
23
+ run,
24
+ setError
25
+ };
26
+ };
27
+ //#endregion
28
+ export { useAction };
@@ -0,0 +1,38 @@
1
+ import { authMessage } from "../auth/auth-messages.js";
2
+ import { useAuth } from "../auth/auth-provider.js";
3
+ import { useCallback, useEffect, useState } from "react";
4
+ import { AuthError } from "@protobase/client";
5
+ //#region src/organizations/use-organizations.ts
6
+ /** The current organization and the person's organizations, read again with `reload` after a change. */
7
+ const useOrganizations = (enabled) => {
8
+ const { session } = useAuth();
9
+ const [current, setCurrent] = useState();
10
+ const [organizations, setOrganizations] = useState([]);
11
+ const [error, setError] = useState();
12
+ const [round, setRound] = useState(0);
13
+ const reload = useCallback(() => setRound((value) => value + 1), []);
14
+ useEffect(() => {
15
+ if (!enabled) return;
16
+ Promise.all([session.organizations.current(), session.organizations.list()]).then(([currentOrganization, list]) => {
17
+ setCurrent(currentOrganization);
18
+ setOrganizations(list);
19
+ }, (failure) => setError(failure instanceof AuthError ? authMessage(failure) : "Could not read your organizations"));
20
+ }, [
21
+ enabled,
22
+ session,
23
+ round
24
+ ]);
25
+ return {
26
+ current,
27
+ organizations,
28
+ ...error && { error },
29
+ reload
30
+ };
31
+ };
32
+ /**
33
+ * Starts the app again in another organization: what it shows, the menu included, follows from the roles there, so it
34
+ * loads afresh rather than keeping what it read for the last one.
35
+ */
36
+ const reopenIn = (basePath) => window.location.assign(`${basePath}/`);
37
+ //#endregion
38
+ export { reopenIn, useOrganizations };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/ui",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
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.7.0",
38
- "@protobase/layout": "0.7.0",
39
- "@protobase/schema": "0.7.0",
37
+ "@protobase/client": "0.8.1",
38
+ "@protobase/layout": "0.8.1",
39
+ "@protobase/schema": "0.8.1",
40
40
  "@tanstack/react-query": "^5.104.1",
41
41
  "@tanstack/react-table": "^9.2.6",
42
42
  "@tanstack/react-virtual": "^3.14.13",