@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.
- 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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fileDiffers, filePatchValue } from "./file-values.js";
|
|
1
2
|
//#region src/live/field-values.ts
|
|
2
3
|
/** What the editor's text input holds for a stored value. */
|
|
3
4
|
const toDraft = (value) => value === null || value === void 0 ? "" : typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
@@ -10,10 +11,11 @@ const textEdited = /* @__PURE__ */ new Set([
|
|
|
10
11
|
"currency",
|
|
11
12
|
"country"
|
|
12
13
|
]);
|
|
13
|
-
const isEditable = (field) => !field.readOnly && field.type !== "json" && field.type !== "uuid" && field.type !== "
|
|
14
|
+
const isEditable = (field) => !field.readOnly && field.type !== "json" && field.type !== "uuid" && field.type !== "timestamp" && field.type !== "relation";
|
|
14
15
|
/** The JSON value to send for an edited field: integers become numbers, empty optional fields become null. */
|
|
15
16
|
const toPatchValue = (field, draft) => {
|
|
16
17
|
if (field.type === "boolean") return Boolean(draft);
|
|
18
|
+
if (field.type === "file") return filePatchValue(draft);
|
|
17
19
|
const text = String(draft).trim();
|
|
18
20
|
if (text === "") return field.nullable ? null : "";
|
|
19
21
|
if (field.type === "integer") return Number(text);
|
|
@@ -23,6 +25,7 @@ const toPatchValue = (field, draft) => {
|
|
|
23
25
|
/** Whether the draft differs from what the server holds, comparing numbers by value. */
|
|
24
26
|
const differs = (field, draft, stored) => {
|
|
25
27
|
if (field.type === "boolean") return Boolean(draft) !== Boolean(stored);
|
|
28
|
+
if (field.type === "file") return fileDiffers(draft, stored);
|
|
26
29
|
const a = String(draft ?? "").trim();
|
|
27
30
|
const b = toDraft(stored);
|
|
28
31
|
if (field.type === "decimal" || field.type === "integer") return a === "" || b === "" ? a !== b : Number(a) !== Number(b);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
//#region src/live/file-values.ts
|
|
2
|
+
const isStoredFile = (value) => typeof value === "object" && value !== null && typeof value.uri === "string";
|
|
3
|
+
const isFileDraft = (value) => typeof value === "object" && value !== null && typeof value.uploaded?.value === "string";
|
|
4
|
+
/** What a file field shows: its name, type and size, whether stored or just uploaded; `undefined` when empty. */
|
|
5
|
+
const fileShown = (value) => {
|
|
6
|
+
if (isFileDraft(value)) return {
|
|
7
|
+
...value.uploaded.file,
|
|
8
|
+
...value.preview && { url: value.preview },
|
|
9
|
+
corrected: value.uploaded.corrected
|
|
10
|
+
};
|
|
11
|
+
if (!isStoredFile(value)) return void 0;
|
|
12
|
+
if ("missing" in value) return {
|
|
13
|
+
name: "File missing",
|
|
14
|
+
type: "",
|
|
15
|
+
missing: true
|
|
16
|
+
};
|
|
17
|
+
return value;
|
|
18
|
+
};
|
|
19
|
+
const units = [
|
|
20
|
+
"bytes",
|
|
21
|
+
"kB",
|
|
22
|
+
"MB",
|
|
23
|
+
"GB"
|
|
24
|
+
];
|
|
25
|
+
/** `48 kB`, `1.2 MB`: decimal units, like the field's `maxSize`. */
|
|
26
|
+
const formatSize = (bytes) => {
|
|
27
|
+
let size = bytes;
|
|
28
|
+
let unit = 0;
|
|
29
|
+
while (size >= 1e3 && unit < units.length - 1) {
|
|
30
|
+
size /= 1e3;
|
|
31
|
+
unit += 1;
|
|
32
|
+
}
|
|
33
|
+
return unit === 0 ? `${bytes} ${bytes === 1 ? "byte" : "bytes"}` : `${size < 10 ? size.toFixed(1) : Math.round(size)} ${units[unit]}`;
|
|
34
|
+
};
|
|
35
|
+
/** Raster images the browser shows; SVG is never shown, since it can carry scripts. */
|
|
36
|
+
const isPreviewable = (type) => type.startsWith("image/") && type !== "image/svg+xml";
|
|
37
|
+
/** The file input's `accept`: the field's allowed types, or nothing when it takes any. */
|
|
38
|
+
const acceptOf = (field) => {
|
|
39
|
+
const accept = field.file?.accept ?? [];
|
|
40
|
+
return accept.length === 0 || accept.includes("*/*") ? void 0 : accept.join(",");
|
|
41
|
+
};
|
|
42
|
+
/** What a save sends for a file field: the ticket of a new upload, `null` to clear it, the stored URI to keep it. */
|
|
43
|
+
const filePatchValue = (draft) => {
|
|
44
|
+
if (isFileDraft(draft)) return draft.uploaded.value;
|
|
45
|
+
if (isStoredFile(draft)) return draft.uri;
|
|
46
|
+
return null;
|
|
47
|
+
};
|
|
48
|
+
/** Whether a file field's draft changes what is stored. */
|
|
49
|
+
const fileDiffers = (draft, stored) => {
|
|
50
|
+
if (isFileDraft(draft)) return true;
|
|
51
|
+
const uri = (value) => isStoredFile(value) ? value.uri : null;
|
|
52
|
+
return uri(draft) !== uri(stored);
|
|
53
|
+
};
|
|
54
|
+
//#endregion
|
|
55
|
+
export { acceptOf, fileDiffers, filePatchValue, fileShown, formatSize, isFileDraft, isPreviewable, isStoredFile };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
import { Checkbox } from "../primitives/checkbox.js";
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
//#region src/organizations/app-roles-picker.tsx
|
|
5
|
+
/** The app roles a member can hold, ticked when held; only those the person may give or take away can change. */
|
|
6
|
+
const AppRolesPicker = ({ roles, selected, grantable, disabled, onChange }) => /* @__PURE__ */ jsx("div", {
|
|
7
|
+
className: "flex flex-wrap gap-x-4 gap-y-2",
|
|
8
|
+
children: roles.filter((role) => role.membership).map((role) => {
|
|
9
|
+
const held = selected.includes(role.name);
|
|
10
|
+
const locked = disabled || !grantable.has(role.name);
|
|
11
|
+
return /* @__PURE__ */ jsxs("label", {
|
|
12
|
+
title: locked && !disabled ? "You cannot give or take away this role" : void 0,
|
|
13
|
+
className: cn("inline-flex items-center gap-1.5 text-[13px]", locked && "text-muted-foreground"),
|
|
14
|
+
children: [/* @__PURE__ */ jsx(Checkbox, {
|
|
15
|
+
checked: held,
|
|
16
|
+
label: role.label,
|
|
17
|
+
onChange: locked ? void 0 : (checked) => onChange(checked ? [...selected, role.name] : selected.filter((name) => name !== role.name)),
|
|
18
|
+
className: cn(locked && "cursor-default opacity-60")
|
|
19
|
+
}), role.label]
|
|
20
|
+
}, role.name);
|
|
21
|
+
})
|
|
22
|
+
});
|
|
23
|
+
//#endregion
|
|
24
|
+
export { AppRolesPicker };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/organizations/invitation-link.ts
|
|
2
|
+
/** The path an emailed invitation link opens, below the app's base path. */
|
|
3
|
+
const invitationLinkPath = "/-/invitation";
|
|
4
|
+
/** The invitation link this page was opened from: its token, or `undefined` for any other page. */
|
|
5
|
+
const readInvitationLink = (href) => {
|
|
6
|
+
const url = new URL(href);
|
|
7
|
+
const token = url.searchParams.get("token");
|
|
8
|
+
return url.pathname.endsWith("/-/invitation") && token ? { token } : void 0;
|
|
9
|
+
};
|
|
10
|
+
/** The app's start page, for the address bar once the link is used: the link's path without the invitation part. */
|
|
11
|
+
const withoutInvitationLink = (href) => {
|
|
12
|
+
return `${new URL(href).pathname.slice(0, -13)}/`;
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { invitationLinkPath, readInvitationLink, withoutInvitationLink };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { Input } from "../primitives/input.js";
|
|
2
|
+
import { Spinner } from "../primitives/spinner.js";
|
|
3
|
+
import { authMessage } from "../auth/auth-messages.js";
|
|
4
|
+
import { useAuth } from "../auth/auth-provider.js";
|
|
5
|
+
import { Button } from "../primitives/button.js";
|
|
6
|
+
import { AuthLayout } from "../auth/auth-layout.js";
|
|
7
|
+
import { organizationRoleLabels, roleLabel } from "./organization-paths.js";
|
|
8
|
+
import { useAction } from "./use-action.js";
|
|
9
|
+
import { useEffect, useState } from "react";
|
|
10
|
+
import { AuthError } from "@protobase/client";
|
|
11
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
12
|
+
//#region src/organizations/invitation-page.tsx
|
|
13
|
+
const SignUp = ({ token, preview, onDone }) => {
|
|
14
|
+
const { session, signInMethods, recheck } = useAuth();
|
|
15
|
+
const [name, setName] = useState("");
|
|
16
|
+
const [password, setPassword] = useState("");
|
|
17
|
+
const { busy, error, run } = useAction();
|
|
18
|
+
const withPassword = signInMethods.includes("password");
|
|
19
|
+
const passwordOptional = signInMethods.includes("emailCode");
|
|
20
|
+
const submit = () => run(async () => {
|
|
21
|
+
await session.organizations.signUpFromInvitation(token, {
|
|
22
|
+
name: name.trim(),
|
|
23
|
+
...withPassword && password && { password }
|
|
24
|
+
});
|
|
25
|
+
onDone();
|
|
26
|
+
recheck();
|
|
27
|
+
});
|
|
28
|
+
return /* @__PURE__ */ jsxs("form", {
|
|
29
|
+
className: "flex flex-col gap-3",
|
|
30
|
+
onSubmit: (event) => {
|
|
31
|
+
event.preventDefault();
|
|
32
|
+
submit();
|
|
33
|
+
},
|
|
34
|
+
children: [
|
|
35
|
+
/* @__PURE__ */ jsxs("p", {
|
|
36
|
+
className: "text-[13px] text-muted-foreground",
|
|
37
|
+
children: [
|
|
38
|
+
"Make your account for ",
|
|
39
|
+
preview.email,
|
|
40
|
+
" to join."
|
|
41
|
+
]
|
|
42
|
+
}),
|
|
43
|
+
error && /* @__PURE__ */ jsx("p", {
|
|
44
|
+
role: "alert",
|
|
45
|
+
className: "text-xs font-medium text-danger-text",
|
|
46
|
+
children: error
|
|
47
|
+
}),
|
|
48
|
+
/* @__PURE__ */ jsx(Input, {
|
|
49
|
+
"aria-label": "Your name",
|
|
50
|
+
placeholder: "Your name",
|
|
51
|
+
autoComplete: "name",
|
|
52
|
+
value: name,
|
|
53
|
+
onChange: (event) => setName(event.target.value)
|
|
54
|
+
}),
|
|
55
|
+
withPassword && /* @__PURE__ */ jsx(Input, {
|
|
56
|
+
"aria-label": "Password",
|
|
57
|
+
type: "password",
|
|
58
|
+
autoComplete: "new-password",
|
|
59
|
+
placeholder: passwordOptional ? `Password (optional, 12+ characters)` : `Password (12+ characters)`,
|
|
60
|
+
value: password,
|
|
61
|
+
onChange: (event) => setPassword(event.target.value)
|
|
62
|
+
}),
|
|
63
|
+
/* @__PURE__ */ jsx(Button, {
|
|
64
|
+
type: "submit",
|
|
65
|
+
variant: "primary",
|
|
66
|
+
loading: busy,
|
|
67
|
+
disabled: !name.trim() || withPassword && !passwordOptional && password.length < 12,
|
|
68
|
+
children: "Create account and join"
|
|
69
|
+
})
|
|
70
|
+
]
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Where an emailed invitation link leads: what it is for, then accepting it signed in as the invited address, signing
|
|
75
|
+
* in first, or making an account for an address without one.
|
|
76
|
+
*/
|
|
77
|
+
const InvitationPage = ({ token, onDone, onSignIn }) => {
|
|
78
|
+
const { session, state, signOut, organizations } = useAuth();
|
|
79
|
+
const [preview, setPreview] = useState();
|
|
80
|
+
const [invalid, setInvalid] = useState();
|
|
81
|
+
const { busy, error, run } = useAction();
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
session.organizations.invitation(token).then(setPreview, (failure) => setInvalid(failure instanceof AuthError ? authMessage(failure) : "This invitation link is not valid"));
|
|
84
|
+
}, [session, token]);
|
|
85
|
+
if (invalid) return /* @__PURE__ */ jsx(AuthLayout, {
|
|
86
|
+
title: "Invitation",
|
|
87
|
+
description: invalid,
|
|
88
|
+
children: /* @__PURE__ */ jsx(Button, {
|
|
89
|
+
className: "w-full",
|
|
90
|
+
onClick: onDone,
|
|
91
|
+
children: "Go to the app"
|
|
92
|
+
})
|
|
93
|
+
});
|
|
94
|
+
if (!preview) return /* @__PURE__ */ jsx(AuthLayout, {
|
|
95
|
+
title: "Invitation",
|
|
96
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
97
|
+
className: "flex items-center justify-center gap-2 text-[13px] text-muted-foreground",
|
|
98
|
+
children: [/* @__PURE__ */ jsx(Spinner, {}), " Loading"]
|
|
99
|
+
})
|
|
100
|
+
});
|
|
101
|
+
const roles = preview.appRoles.map((name) => roleLabel(organizations?.roles ?? [], name));
|
|
102
|
+
const description = `${preview.inviter.name} invites you to ${preview.organization.name} as ${organizationRoleLabels[preview.role].toLowerCase()}${roles.length > 0 ? `, with ${roles.join(", ")}` : ""}.`;
|
|
103
|
+
const user = state.kind === "signed-in" ? state.user : void 0;
|
|
104
|
+
if (user && user.email.toLowerCase() !== preview.email.toLowerCase()) return /* @__PURE__ */ jsxs(AuthLayout, {
|
|
105
|
+
title: `Join ${preview.organization.name}`,
|
|
106
|
+
description,
|
|
107
|
+
children: [/* @__PURE__ */ jsxs("p", {
|
|
108
|
+
className: "mb-3 text-[13px] text-muted-foreground",
|
|
109
|
+
children: [
|
|
110
|
+
"This invitation is for ",
|
|
111
|
+
preview.email,
|
|
112
|
+
", and you are signed in as ",
|
|
113
|
+
user.email,
|
|
114
|
+
"."
|
|
115
|
+
]
|
|
116
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
117
|
+
className: "w-full",
|
|
118
|
+
onClick: () => void signOut(),
|
|
119
|
+
children: "Sign out to accept it"
|
|
120
|
+
})]
|
|
121
|
+
});
|
|
122
|
+
return /* @__PURE__ */ jsx(AuthLayout, {
|
|
123
|
+
title: `Join ${preview.organization.name}`,
|
|
124
|
+
description,
|
|
125
|
+
children: user ? /* @__PURE__ */ jsxs("div", {
|
|
126
|
+
className: "flex flex-col gap-3",
|
|
127
|
+
children: [error && /* @__PURE__ */ jsx("p", {
|
|
128
|
+
role: "alert",
|
|
129
|
+
className: "text-xs font-medium text-danger-text",
|
|
130
|
+
children: error
|
|
131
|
+
}), /* @__PURE__ */ jsxs(Button, {
|
|
132
|
+
variant: "primary",
|
|
133
|
+
className: "w-full",
|
|
134
|
+
loading: busy,
|
|
135
|
+
onClick: () => void run(async () => {
|
|
136
|
+
await session.organizations.accept(token);
|
|
137
|
+
onDone();
|
|
138
|
+
}),
|
|
139
|
+
children: ["Join ", preview.organization.name]
|
|
140
|
+
})]
|
|
141
|
+
}) : preview.hasAccount ? /* @__PURE__ */ jsxs(Button, {
|
|
142
|
+
variant: "primary",
|
|
143
|
+
className: "w-full",
|
|
144
|
+
onClick: onSignIn,
|
|
145
|
+
children: [
|
|
146
|
+
"Sign in as ",
|
|
147
|
+
preview.email,
|
|
148
|
+
" to join"
|
|
149
|
+
]
|
|
150
|
+
}) : /* @__PURE__ */ jsx(SignUp, {
|
|
151
|
+
token,
|
|
152
|
+
preview,
|
|
153
|
+
onDone
|
|
154
|
+
})
|
|
155
|
+
});
|
|
156
|
+
};
|
|
157
|
+
//#endregion
|
|
158
|
+
export { InvitationPage };
|
|
@@ -0,0 +1,179 @@
|
|
|
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 { AccountSection, SectionError } from "../account/account-section.js";
|
|
6
|
+
import { grantableRoles, organizationRoleLabels, roleLabel } from "./organization-paths.js";
|
|
7
|
+
import { useAction } from "./use-action.js";
|
|
8
|
+
import { AppRolesPicker } from "./app-roles-picker.js";
|
|
9
|
+
import { assignableOrganizationRoles } from "./members-page.js";
|
|
10
|
+
import { useCallback, useEffect, useState } from "react";
|
|
11
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
12
|
+
//#region src/organizations/invitations-page.tsx
|
|
13
|
+
const LinkNotice = ({ link }) => link ? /* @__PURE__ */ jsxs("p", {
|
|
14
|
+
className: "mt-3 break-all rounded-md border bg-muted px-3 py-2 text-xs",
|
|
15
|
+
children: ["This app cannot send email, so pass this link on yourself. It works once, until the invitation expires: ", /* @__PURE__ */ jsx("span", {
|
|
16
|
+
className: "font-mono",
|
|
17
|
+
children: link
|
|
18
|
+
})]
|
|
19
|
+
}) : null;
|
|
20
|
+
const InviteForm = ({ current, onSent }) => {
|
|
21
|
+
const { session, organizations } = useAuth();
|
|
22
|
+
const roles = organizations?.roles ?? [];
|
|
23
|
+
const [email, setEmail] = useState("");
|
|
24
|
+
const [role, setRole] = useState("member");
|
|
25
|
+
const [appRoles, setAppRoles] = useState([]);
|
|
26
|
+
const [link, setLink] = useState();
|
|
27
|
+
const { busy, error, run } = useAction();
|
|
28
|
+
const invite = () => run(async () => {
|
|
29
|
+
const sent = await session.organizations.invite(current.organization.id, {
|
|
30
|
+
email: email.trim(),
|
|
31
|
+
role,
|
|
32
|
+
appRoles
|
|
33
|
+
});
|
|
34
|
+
setLink(sent.link);
|
|
35
|
+
setEmail("");
|
|
36
|
+
setAppRoles([]);
|
|
37
|
+
onSent();
|
|
38
|
+
});
|
|
39
|
+
return /* @__PURE__ */ jsxs(AccountSection, {
|
|
40
|
+
title: "Invite someone",
|
|
41
|
+
description: "They get an email with a link to join, or to make an account first. You can give the app roles you hold, and those your roles grant.",
|
|
42
|
+
children: [
|
|
43
|
+
/* @__PURE__ */ jsx(SectionError, { message: error }),
|
|
44
|
+
/* @__PURE__ */ jsxs("form", {
|
|
45
|
+
className: "flex flex-col gap-3",
|
|
46
|
+
onSubmit: (event) => {
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
invite();
|
|
49
|
+
},
|
|
50
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
51
|
+
className: "flex flex-col gap-2 sm:flex-row",
|
|
52
|
+
children: [
|
|
53
|
+
/* @__PURE__ */ jsx(Input, {
|
|
54
|
+
"aria-label": "Email",
|
|
55
|
+
type: "email",
|
|
56
|
+
placeholder: "name@example.com",
|
|
57
|
+
value: email,
|
|
58
|
+
onChange: (event) => setEmail(event.target.value),
|
|
59
|
+
wrapperClassName: "flex-1"
|
|
60
|
+
}),
|
|
61
|
+
/* @__PURE__ */ jsx("select", {
|
|
62
|
+
"aria-label": "Organization role",
|
|
63
|
+
value: role,
|
|
64
|
+
onChange: (event) => setRole(event.target.value),
|
|
65
|
+
className: "h-8 rounded-md border border-border-strong bg-background px-2 text-[13px]",
|
|
66
|
+
children: assignableOrganizationRoles(current).map((option) => /* @__PURE__ */ jsx("option", {
|
|
67
|
+
value: option,
|
|
68
|
+
children: organizationRoleLabels[option]
|
|
69
|
+
}, option))
|
|
70
|
+
}),
|
|
71
|
+
/* @__PURE__ */ jsx(Button, {
|
|
72
|
+
type: "submit",
|
|
73
|
+
variant: "primary",
|
|
74
|
+
loading: busy,
|
|
75
|
+
disabled: !email.trim(),
|
|
76
|
+
children: "Invite"
|
|
77
|
+
})
|
|
78
|
+
]
|
|
79
|
+
}), /* @__PURE__ */ jsx(AppRolesPicker, {
|
|
80
|
+
roles,
|
|
81
|
+
selected: appRoles,
|
|
82
|
+
grantable: grantableRoles(roles, current),
|
|
83
|
+
onChange: setAppRoles
|
|
84
|
+
})]
|
|
85
|
+
}),
|
|
86
|
+
/* @__PURE__ */ jsx(LinkNotice, { link })
|
|
87
|
+
]
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
const PendingRow = ({ invitation, organizationId, onChanged }) => {
|
|
91
|
+
const { session, organizations } = useAuth();
|
|
92
|
+
const [link, setLink] = useState();
|
|
93
|
+
const { busy, error, run } = useAction();
|
|
94
|
+
const expired = new Date(invitation.expiresAt).getTime() < Date.now();
|
|
95
|
+
return /* @__PURE__ */ jsxs("li", {
|
|
96
|
+
className: "px-3 py-2.5",
|
|
97
|
+
children: [
|
|
98
|
+
/* @__PURE__ */ jsxs("div", {
|
|
99
|
+
className: "flex flex-wrap items-center gap-2",
|
|
100
|
+
children: [
|
|
101
|
+
/* @__PURE__ */ jsxs("span", {
|
|
102
|
+
className: "min-w-0 flex-1",
|
|
103
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
104
|
+
className: "block truncate text-[13px] font-medium",
|
|
105
|
+
children: invitation.email
|
|
106
|
+
}), /* @__PURE__ */ jsxs("span", {
|
|
107
|
+
className: "block text-xs text-muted-foreground",
|
|
108
|
+
children: [
|
|
109
|
+
organizationRoleLabels[invitation.role],
|
|
110
|
+
invitation.appRoles.length > 0 && ` · ${invitation.appRoles.map((name) => roleLabel(organizations?.roles ?? [], name)).join(", ")}`,
|
|
111
|
+
" · ",
|
|
112
|
+
expired ? "Expired" : `Until ${new Date(invitation.expiresAt).toLocaleDateString()}`
|
|
113
|
+
]
|
|
114
|
+
})]
|
|
115
|
+
}),
|
|
116
|
+
/* @__PURE__ */ jsx(Button, {
|
|
117
|
+
size: "sm",
|
|
118
|
+
loading: busy,
|
|
119
|
+
onClick: () => void run(async () => {
|
|
120
|
+
setLink((await session.organizations.resend(organizationId, invitation)).link);
|
|
121
|
+
onChanged();
|
|
122
|
+
}),
|
|
123
|
+
children: "Send again"
|
|
124
|
+
}),
|
|
125
|
+
/* @__PURE__ */ jsx(Button, {
|
|
126
|
+
size: "sm",
|
|
127
|
+
variant: "danger",
|
|
128
|
+
loading: busy,
|
|
129
|
+
onClick: () => void run(async () => {
|
|
130
|
+
await session.organizations.revoke(invitation.id);
|
|
131
|
+
onChanged();
|
|
132
|
+
}),
|
|
133
|
+
children: "Revoke"
|
|
134
|
+
})
|
|
135
|
+
]
|
|
136
|
+
}),
|
|
137
|
+
/* @__PURE__ */ jsx(SectionError, { message: error }),
|
|
138
|
+
/* @__PURE__ */ jsx(LinkNotice, { link })
|
|
139
|
+
]
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
/** Inviting people to the organization one works in, and the invitations not yet accepted, for its owners and admins. */
|
|
143
|
+
const InvitationsPage = ({ current }) => {
|
|
144
|
+
const { session } = useAuth();
|
|
145
|
+
const [invitations, setInvitations] = useState([]);
|
|
146
|
+
const organizationId = current?.organization?.id;
|
|
147
|
+
const load = useCallback(() => {
|
|
148
|
+
if (organizationId) session.organizations.invitations(organizationId).then((list) => setInvitations(list.filter((invitation) => invitation.status === "pending")), () => setInvitations([]));
|
|
149
|
+
}, [session, organizationId]);
|
|
150
|
+
useEffect(load, [load]);
|
|
151
|
+
if (!current?.organization || !organizationId) return null;
|
|
152
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
153
|
+
className: "mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 py-6 md:px-6",
|
|
154
|
+
children: [
|
|
155
|
+
/* @__PURE__ */ jsx(PageHeader, {
|
|
156
|
+
title: "Invitations",
|
|
157
|
+
subtitle: `People invited to ${current.organization.name}.`
|
|
158
|
+
}),
|
|
159
|
+
/* @__PURE__ */ jsx(InviteForm, {
|
|
160
|
+
current,
|
|
161
|
+
onSent: load
|
|
162
|
+
}),
|
|
163
|
+
/* @__PURE__ */ jsx(AccountSection, {
|
|
164
|
+
title: "Not accepted yet",
|
|
165
|
+
description: invitations.length > 0 ? "Sending again gives a new link, which replaces the earlier one." : "Nobody is waiting.",
|
|
166
|
+
children: invitations.length > 0 && /* @__PURE__ */ jsx("ul", {
|
|
167
|
+
className: "divide-y rounded-md border",
|
|
168
|
+
children: invitations.map((invitation) => /* @__PURE__ */ jsx(PendingRow, {
|
|
169
|
+
invitation,
|
|
170
|
+
organizationId,
|
|
171
|
+
onChanged: load
|
|
172
|
+
}, invitation.id))
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
]
|
|
176
|
+
});
|
|
177
|
+
};
|
|
178
|
+
//#endregion
|
|
179
|
+
export { InvitationsPage };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Spinner } from "../primitives/spinner.js";
|
|
2
|
+
import { PageHeader } from "../app-shell/page-header.js";
|
|
3
|
+
import { authMessage } from "../auth/auth-messages.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 { grantableRoles, organizationRoleLabels } from "./organization-paths.js";
|
|
8
|
+
import { useAction } from "./use-action.js";
|
|
9
|
+
import { AppRolesPicker } from "./app-roles-picker.js";
|
|
10
|
+
import { useCallback, useEffect, useState } from "react";
|
|
11
|
+
import { AuthError } from "@protobase/client";
|
|
12
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
13
|
+
//#region src/organizations/members-page.tsx
|
|
14
|
+
/** The organization roles the person may set: owners every one, admins those below owner. */
|
|
15
|
+
const assignableOrganizationRoles = (current) => current?.role === "owner" ? [
|
|
16
|
+
"owner",
|
|
17
|
+
"admin",
|
|
18
|
+
"member"
|
|
19
|
+
] : ["admin", "member"];
|
|
20
|
+
const MemberRow = ({ member, current, onChanged }) => {
|
|
21
|
+
const { session, organizations } = useAuth();
|
|
22
|
+
const organizationId = current.organization.id;
|
|
23
|
+
const { busy, error, run } = useAction();
|
|
24
|
+
const roles = organizations?.roles ?? [];
|
|
25
|
+
const self = member.id === current.memberId;
|
|
26
|
+
const organizationRoles = assignableOrganizationRoles(current);
|
|
27
|
+
const roleLocked = member.role === "owner" && current.role !== "owner";
|
|
28
|
+
return /* @__PURE__ */ jsxs("li", {
|
|
29
|
+
className: "flex flex-col gap-2 px-3 py-3",
|
|
30
|
+
children: [
|
|
31
|
+
/* @__PURE__ */ jsxs("div", {
|
|
32
|
+
className: "flex flex-wrap items-center gap-2",
|
|
33
|
+
children: [
|
|
34
|
+
/* @__PURE__ */ jsxs("span", {
|
|
35
|
+
className: "min-w-0 flex-1",
|
|
36
|
+
children: [/* @__PURE__ */ jsxs("span", {
|
|
37
|
+
className: "block truncate text-[13px] font-medium",
|
|
38
|
+
children: [member.name, self && " (you)"]
|
|
39
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
40
|
+
className: "block truncate text-xs text-muted-foreground",
|
|
41
|
+
children: member.email
|
|
42
|
+
})]
|
|
43
|
+
}),
|
|
44
|
+
/* @__PURE__ */ jsx("select", {
|
|
45
|
+
"aria-label": `Organization role of ${member.name}`,
|
|
46
|
+
value: member.role,
|
|
47
|
+
disabled: busy || roleLocked,
|
|
48
|
+
onChange: (event) => void run(async () => {
|
|
49
|
+
await session.organizations.setRole(organizationId, member.id, event.target.value);
|
|
50
|
+
onChanged();
|
|
51
|
+
}),
|
|
52
|
+
className: "h-7 rounded-md border border-border-strong bg-background px-2 text-xs",
|
|
53
|
+
children: (roleLocked ? [member.role] : organizationRoles).map((role) => /* @__PURE__ */ jsx("option", {
|
|
54
|
+
value: role,
|
|
55
|
+
children: organizationRoleLabels[role]
|
|
56
|
+
}, role))
|
|
57
|
+
}),
|
|
58
|
+
!self && /* @__PURE__ */ jsx(Button, {
|
|
59
|
+
size: "sm",
|
|
60
|
+
variant: "danger",
|
|
61
|
+
loading: busy,
|
|
62
|
+
disabled: roleLocked,
|
|
63
|
+
onClick: () => void run(async () => {
|
|
64
|
+
await session.organizations.removeMember(organizationId, member.id);
|
|
65
|
+
onChanged();
|
|
66
|
+
}),
|
|
67
|
+
children: "Remove"
|
|
68
|
+
})
|
|
69
|
+
]
|
|
70
|
+
}),
|
|
71
|
+
/* @__PURE__ */ jsx(AppRolesPicker, {
|
|
72
|
+
roles,
|
|
73
|
+
selected: member.appRoles,
|
|
74
|
+
grantable: grantableRoles(roles, current),
|
|
75
|
+
disabled: busy,
|
|
76
|
+
onChange: (selected) => void run(async () => {
|
|
77
|
+
await session.organizations.setAppRoles(member.id, selected);
|
|
78
|
+
onChanged();
|
|
79
|
+
})
|
|
80
|
+
}),
|
|
81
|
+
/* @__PURE__ */ jsx(SectionError, { message: error })
|
|
82
|
+
]
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* The members of the organization one works in, for its owners and admins: organization roles, which decide who
|
|
87
|
+
* manages it, and app roles, which decide what each sees and does; only roles the person may give can change.
|
|
88
|
+
*/
|
|
89
|
+
const MembersPage = ({ current }) => {
|
|
90
|
+
const { session } = useAuth();
|
|
91
|
+
const [members, setMembers] = useState();
|
|
92
|
+
const [error, setError] = useState();
|
|
93
|
+
const organizationId = current?.organization?.id;
|
|
94
|
+
const load = useCallback(() => {
|
|
95
|
+
if (organizationId) session.organizations.members(organizationId).then(setMembers, (failure) => setError(failure instanceof AuthError ? authMessage(failure) : "Could not read the members"));
|
|
96
|
+
}, [session, organizationId]);
|
|
97
|
+
useEffect(load, [load]);
|
|
98
|
+
if (!current?.organization) return null;
|
|
99
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
100
|
+
className: "mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 py-6 md:px-6",
|
|
101
|
+
children: [/* @__PURE__ */ jsx(PageHeader, {
|
|
102
|
+
title: "Members",
|
|
103
|
+
subtitle: `Who works in ${current.organization.name}, and with which roles.`
|
|
104
|
+
}), /* @__PURE__ */ jsxs(AccountSection, {
|
|
105
|
+
title: "Members",
|
|
106
|
+
description: "Owners and admins manage the organization; app roles decide what each member sees and does in the app.",
|
|
107
|
+
children: [/* @__PURE__ */ jsx(SectionError, { message: error }), members ? /* @__PURE__ */ jsx("ul", {
|
|
108
|
+
className: "divide-y rounded-md border",
|
|
109
|
+
children: members.map((member) => /* @__PURE__ */ jsx(MemberRow, {
|
|
110
|
+
member,
|
|
111
|
+
current,
|
|
112
|
+
onChanged: load
|
|
113
|
+
}, `${member.id}/${member.role}/${member.appRoles.join()}`))
|
|
114
|
+
}) : !error && /* @__PURE__ */ jsxs("div", {
|
|
115
|
+
className: "flex items-center gap-2 text-[13px] text-muted-foreground",
|
|
116
|
+
children: [/* @__PURE__ */ jsx(Spinner, {}), " Loading"]
|
|
117
|
+
})]
|
|
118
|
+
})]
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
//#endregion
|
|
122
|
+
export { MembersPage, assignableOrganizationRoles };
|