EventBuzz_Console/src/app/components/forms/ShareFormPanel.tsx

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>
);
}