354 lines
12 KiB
TypeScript
354 lines
12 KiB
TypeScript
import React, { useCallback, useEffect, useState } from "react";
|
|
import { Trash2, X } from "lucide-react";
|
|
import { rolesApi, type FormGrant, type Role } from "../../../services/api/roles";
|
|
import { usersApi } from "../../../services/api/users";
|
|
import type { User } from "../../../services/api/types";
|
|
import { usePermission } from "../../hooks/usePermission";
|
|
|
|
const GRANT_ACTIONS = ["read", "write", "delete"] as const;
|
|
|
|
export function ShareFormPanel({ formId, onClose }: { formId: string; onClose: () => void }) {
|
|
const { canModule } = usePermission();
|
|
const [visibility, setVisibility] = useState<"private" | "org">("org");
|
|
const [grants, setGrants] = useState<FormGrant[]>([]);
|
|
const [users, setUsers] = useState<User[]>([]);
|
|
const [roles, setRoles] = useState<Role[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [subjectType, setSubjectType] = useState<"user" | "role">("user");
|
|
const [subjectId, setSubjectId] = useState("");
|
|
const [newActions, setNewActions] = useState<string[]>(["read"]);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
setError("");
|
|
const [gRes, uRes, rRes] = await Promise.all([
|
|
rolesApi.listFormGrants(formId),
|
|
usersApi.list(),
|
|
rolesApi.list(),
|
|
]);
|
|
if (gRes.error) {
|
|
setError(gRes.error.message || "Failed to load sharing settings");
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
if (gRes.data?.defaultVisibility) {
|
|
setVisibility(gRes.data.defaultVisibility as "private" | "org");
|
|
}
|
|
setGrants(gRes.data?.grants ?? []);
|
|
if (uRes.data?.users) setUsers(uRes.data.users);
|
|
if (rRes.data?.roles) setRoles(rRes.data.roles);
|
|
setLoading(false);
|
|
}, [formId]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const handleVisibilityChange = async (v: "private" | "org") => {
|
|
setVisibility(v);
|
|
setBusy(true);
|
|
setError("");
|
|
const res = await rolesApi.updateFormVisibility(formId, v);
|
|
if (res.error) {
|
|
setError(res.error.message || "Failed to update visibility");
|
|
void load();
|
|
}
|
|
setBusy(false);
|
|
};
|
|
|
|
const toggleNewAction = (action: string) => {
|
|
setNewActions((prev) =>
|
|
prev.includes(action) ? prev.filter((a) => a !== action) : [...prev, action],
|
|
);
|
|
};
|
|
|
|
const handleAddGrant = async () => {
|
|
if (!subjectId) {
|
|
setError("Select a user or role");
|
|
return;
|
|
}
|
|
if (!newActions.length) {
|
|
setError("Select at least one permission");
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
setError("");
|
|
const res = await rolesApi.createFormGrant(formId, {
|
|
subjectType,
|
|
subjectId,
|
|
actions: newActions,
|
|
});
|
|
if (res.error) {
|
|
setError(res.error.message || "Failed to add grant");
|
|
setBusy(false);
|
|
return;
|
|
}
|
|
setSubjectId("");
|
|
setNewActions(["read"]);
|
|
await load();
|
|
setBusy(false);
|
|
};
|
|
|
|
const handleRemoveGrant = async (grantId: string) => {
|
|
setBusy(true);
|
|
setError("");
|
|
const res = await rolesApi.removeFormGrant(formId, grantId);
|
|
if (res.error) {
|
|
setError(res.error.message || "Failed to remove grant");
|
|
} else {
|
|
await load();
|
|
}
|
|
setBusy(false);
|
|
};
|
|
|
|
if (!canModule("forms", "write")) return null;
|
|
|
|
const overlayStyle: React.CSSProperties = {
|
|
position: "fixed",
|
|
inset: 0,
|
|
background: "rgba(0,0,0,.4)",
|
|
zIndex: 200,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
padding: 16,
|
|
};
|
|
|
|
const panelStyle: React.CSSProperties = {
|
|
background: "var(--card)",
|
|
padding: 20,
|
|
borderRadius: 12,
|
|
minWidth: 360,
|
|
maxWidth: 480,
|
|
width: "100%",
|
|
maxHeight: "90vh",
|
|
overflow: "auto",
|
|
border: "1px solid var(--border)",
|
|
};
|
|
|
|
const labelStyle: React.CSSProperties = {
|
|
display: "block",
|
|
fontSize: "var(--text-xs)",
|
|
color: "var(--muted)",
|
|
marginBottom: 4,
|
|
};
|
|
|
|
const inputStyle: React.CSSProperties = {
|
|
width: "100%",
|
|
padding: 8,
|
|
borderRadius: 8,
|
|
border: "1px solid var(--border)",
|
|
background: "var(--background)",
|
|
color: "var(--foreground)",
|
|
fontSize: "var(--text-sm)",
|
|
};
|
|
|
|
return (
|
|
<div style={overlayStyle} onClick={onClose}>
|
|
<div style={panelStyle} onClick={(e) => e.stopPropagation()}>
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
|
|
<h3 style={{ margin: 0, fontSize: "var(--text-base)" }}>Share form</h3>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", padding: 4 }}
|
|
aria-label="Close"
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<p style={{ color: "var(--destructive)", fontSize: 13, margin: "0 0 12px" }}>{error}</p>
|
|
)}
|
|
|
|
{loading ? (
|
|
<p style={{ color: "var(--muted)", fontSize: 13 }}>Loading…</p>
|
|
) : (
|
|
<>
|
|
<div style={{ marginBottom: 16 }}>
|
|
<label style={labelStyle}>Default visibility</label>
|
|
<select
|
|
value={visibility}
|
|
disabled={busy}
|
|
onChange={(e) => void handleVisibilityChange(e.target.value as "private" | "org")}
|
|
style={inputStyle}
|
|
>
|
|
<option value="org">Organisation visible — anyone with forms access can see this form</option>
|
|
<option value="private">Private — only you and people granted below</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div style={{ marginBottom: 16 }}>
|
|
<label style={labelStyle}>People with access</label>
|
|
{grants.length === 0 ? (
|
|
<p style={{ fontSize: 13, color: "var(--muted)", margin: "8px 0 0" }}>
|
|
No explicit grants. {visibility === "org" ? "Form is visible to your organisation." : "Only the creator can access this form."}
|
|
</p>
|
|
) : (
|
|
<ul style={{ listStyle: "none", margin: "8px 0 0", padding: 0 }}>
|
|
{grants.map((g) => (
|
|
<li
|
|
key={g._id}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
padding: "8px 0",
|
|
borderBottom: "1px solid var(--border)",
|
|
fontSize: 13,
|
|
}}
|
|
>
|
|
<span>
|
|
<strong>{g.subjectLabel || g.subjectId}</strong>
|
|
<span style={{ color: "var(--muted)", marginLeft: 6 }}>
|
|
({g.subjectType}) — {(g.actions || []).join(", ")}
|
|
</span>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
disabled={busy}
|
|
onClick={() => void handleRemoveGrant(g._id)}
|
|
style={{
|
|
background: "none",
|
|
border: "none",
|
|
cursor: busy ? "not-allowed" : "pointer",
|
|
color: "var(--destructive)",
|
|
padding: 4,
|
|
}}
|
|
title="Remove access"
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
<div
|
|
style={{
|
|
padding: 12,
|
|
borderRadius: 8,
|
|
border: "1px solid var(--border)",
|
|
background: "var(--background)",
|
|
marginBottom: 16,
|
|
}}
|
|
>
|
|
<p style={{ margin: "0 0 10px", fontSize: 13, fontWeight: 600 }}>Add access</p>
|
|
<div style={{ display: "flex", gap: 8, marginBottom: 10 }}>
|
|
<button
|
|
type="button"
|
|
disabled={busy}
|
|
onClick={() => {
|
|
setSubjectType("user");
|
|
setSubjectId("");
|
|
}}
|
|
style={{
|
|
flex: 1,
|
|
padding: "6px 10px",
|
|
borderRadius: 6,
|
|
border: "1px solid var(--border)",
|
|
background: subjectType === "user" ? "var(--primary)" : "transparent",
|
|
color: subjectType === "user" ? "#fff" : "var(--foreground)",
|
|
cursor: "pointer",
|
|
fontSize: 12,
|
|
}}
|
|
>
|
|
User
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={busy}
|
|
onClick={() => {
|
|
setSubjectType("role");
|
|
setSubjectId("");
|
|
}}
|
|
style={{
|
|
flex: 1,
|
|
padding: "6px 10px",
|
|
borderRadius: 6,
|
|
border: "1px solid var(--border)",
|
|
background: subjectType === "role" ? "var(--primary)" : "transparent",
|
|
color: subjectType === "role" ? "#fff" : "var(--foreground)",
|
|
cursor: "pointer",
|
|
fontSize: 12,
|
|
}}
|
|
>
|
|
Role
|
|
</button>
|
|
</div>
|
|
<select
|
|
value={subjectId}
|
|
disabled={busy}
|
|
onChange={(e) => setSubjectId(e.target.value)}
|
|
style={{ ...inputStyle, marginBottom: 10 }}
|
|
>
|
|
<option value="">Select {subjectType === "user" ? "user" : "role"}…</option>
|
|
{subjectType === "user"
|
|
? users.map((u) => (
|
|
<option key={u._id} value={u._id}>
|
|
{[u.firstName, u.lastName].filter(Boolean).join(" ") || u.email} ({u.email})
|
|
</option>
|
|
))
|
|
: roles.map((r) => (
|
|
<option key={r._id} value={r._id}>
|
|
{r.name}{r.isSystem ? " (system)" : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<div style={{ display: "flex", gap: 12, marginBottom: 10, flexWrap: "wrap" }}>
|
|
{GRANT_ACTIONS.map((a) => (
|
|
<label key={a} style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 13, cursor: "pointer" }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={newActions.includes(a)}
|
|
disabled={busy}
|
|
onChange={() => toggleNewAction(a)}
|
|
/>
|
|
{a}
|
|
</label>
|
|
))}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
disabled={busy || !subjectId}
|
|
onClick={() => void handleAddGrant()}
|
|
style={{
|
|
padding: "8px 14px",
|
|
borderRadius: 8,
|
|
border: "none",
|
|
background: "var(--primary)",
|
|
color: "#fff",
|
|
cursor: busy || !subjectId ? "not-allowed" : "pointer",
|
|
fontSize: 13,
|
|
opacity: busy || !subjectId ? 0.6 : 1,
|
|
}}
|
|
>
|
|
Add grant
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
style={{
|
|
padding: "8px 16px",
|
|
borderRadius: 8,
|
|
border: "1px solid var(--border)",
|
|
background: "transparent",
|
|
cursor: "pointer",
|
|
fontSize: 13,
|
|
}}
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|