import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { ArrowLeft, Check, ListChecks, Search, Share2, Shield, User, Users, X, } from "lucide-react"; import { Button } from "@/components/button"; import { Input } from "@/components/input"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/dropdown-menu"; import { Select2 } from "@/components/select2"; import { getCredentialAccess, getRoles, revokeCredentialAccess, shareCredential, type CredentialPermissionLevel, type ShareTarget, } from "@/api/rbac-api"; import { getUserList, type AccessRecord } from "@/main-axios"; import type { Credential } from "@/types/ui-types"; import { getErrorMessage } from "@/lib/error-message"; const PERMISSION_LEVELS: CredentialPermissionLevel[] = ["use", "manage"]; const LEVEL_KEYS: Record< CredentialPermissionLevel, { label: string; description: string } > = { use: { label: "credentials.share.levelUse", description: "credentials.share.levelUseDesc", }, manage: { label: "credentials.share.levelManage", description: "credentials.share.levelManageDesc", }, }; const EXPIRY_PRESETS = [ { key: "never", hours: null }, { key: "oneHour", hours: 1 }, { key: "oneDay", hours: 24 }, { key: "sevenDays", hours: 24 * 7 }, { key: "thirtyDays", hours: 24 * 30 }, { key: "custom", hours: undefined }, ] as const; type ExpiryPresetKey = (typeof EXPIRY_PRESETS)[number]["key"]; function levelOf(record: AccessRecord): CredentialPermissionLevel { return record.permissionLevel === "manage" ? "manage" : "use"; } export function CredentialShareModal({ credential, onClose, }: { credential: Credential | null; onClose: () => void; }) { const { t } = useTranslation(); const [targetTab, setTargetTab] = useState<"user" | "role">("user"); const [search, setSearch] = useState(""); const [shareUsers, setShareUsers] = useState< { id: string; username: string }[] >([]); const [shareRoles, setShareRoles] = useState< { id: number; name: string; displayName?: string }[] >([]); const [selectedUserIds, setSelectedUserIds] = useState>( new Set(), ); const [selectedRoleIds, setSelectedRoleIds] = useState>( new Set(), ); const [permissionLevel, setPermissionLevel] = useState("use"); const [expiryPreset, setExpiryPreset] = useState("never"); const [customHours, setCustomHours] = useState(""); const [accessList, setAccessList] = useState([]); const [loadError, setLoadError] = useState(false); const [submitting, setSubmitting] = useState(false); const credentialId = credential ? Number(credential.id) : null; const refreshAccessList = useCallback(async () => { if (!credentialId) return; setAccessList((await getCredentialAccess(credentialId)).access ?? []); }, [credentialId]); useEffect(() => { setSearch(""); setSelectedUserIds(new Set()); setSelectedRoleIds(new Set()); setPermissionLevel("use"); setExpiryPreset("never"); setCustomHours(""); setTargetTab("user"); setAccessList([]); setLoadError(false); if (!credentialId) return; Promise.all([ getCredentialAccess(credentialId).catch(() => ({ access: [] })), getUserList().catch(() => ({ users: [] })), getRoles().catch(() => ({ roles: [] })), ]) .then(([accessRes, usersRes, rolesRes]) => { setAccessList(accessRes.access ?? []); setShareUsers( (usersRes.users ?? []).map((u) => ({ id: String(u.userId), username: u.username, })), ); setShareRoles( (rolesRes.roles ?? []) .filter((r) => !r.isSystem) .map((r) => ({ id: Number(r.id), name: r.name, displayName: r.displayName, })), ); }) .catch(() => setLoadError(true)); }, [credentialId]); const filteredUsers = useMemo(() => { const q = search.trim().toLowerCase(); return q ? shareUsers.filter((u) => u.username.toLowerCase().includes(q)) : shareUsers; }, [shareUsers, search]); const filteredRoles = useMemo(() => { const q = search.trim().toLowerCase(); return q ? shareRoles.filter( (r) => r.name.toLowerCase().includes(q) || (r.displayName ?? "").toLowerCase().includes(q), ) : shareRoles; }, [shareRoles, search]); const selectedCount = selectedUserIds.size + selectedRoleIds.size; const durationHours = (() => { if (expiryPreset === "never") return undefined; if (expiryPreset === "custom") { const hours = Number(customHours); return Number.isFinite(hours) && hours > 0 ? hours : undefined; } return ( EXPIRY_PRESETS.find((p) => p.key === expiryPreset)?.hours ?? undefined ); })(); async function handleShare() { if (!credentialId || selectedCount === 0) return; const targets: ShareTarget[] = [ ...Array.from(selectedUserIds, (id) => ({ type: "user" as const, id })), ...Array.from(selectedRoleIds, (id) => ({ type: "role" as const, id })), ]; setSubmitting(true); try { await shareCredential( credentialId, targets, permissionLevel, durationHours, ); await refreshAccessList(); setSelectedUserIds(new Set()); setSelectedRoleIds(new Set()); toast.success(t("credentials.share.shared")); } catch (e) { toast.error(getErrorMessage(e)); } finally { setSubmitting(false); } } // No update route for credential grants, so re-share with the time left. async function handleLevelChange( record: AccessRecord, level: CredentialPermissionLevel, ) { if (!credentialId || levelOf(record) === level) return; const target: ShareTarget | null = record.targetType === "role" && record.roleId != null ? { type: "role", id: Number(record.roleId) } : record.userId ? { type: "user", id: String(record.userId) } : null; if (!target) return; let remainingHours: number | undefined; if (record.expiresAt) { remainingHours = (new Date(record.expiresAt).getTime() - Date.now()) / 3_600_000; if (remainingHours <= 0) { toast.error(t("hosts.sharing.accessUpdateFailed")); return; } } try { await shareCredential(credentialId, [target], level, remainingHours); await refreshAccessList(); toast.success(t("hosts.sharing.accessUpdated")); } catch { toast.error(t("hosts.sharing.accessUpdateFailed")); } } async function handleRevoke(record: AccessRecord) { if (!credentialId) return; try { await revokeCredentialAccess(credentialId, record.id); setAccessList((prev) => prev.filter((entry) => entry.id !== record.id)); toast.success(t("credentials.share.accessRevoked")); } catch (e) { toast.error(getErrorMessage(e)); } } if (!credential) return null; return (
{loadError && (
{t("hosts.sharing.loadError")}
)}
{t("hosts.sharing.shareWithSection")}
{t("hosts.docsLink")}

{t("credentials.share.description")}

{(["user", "role"] as const).map((tab) => ( ))}
setSearch(e.target.value)} className="pl-8" />
{targetTab === "user" && (filteredUsers.length === 0 ? (
{t("hosts.sharing.noMatches")}
) : ( filteredUsers.map((user) => { const isSelected = selectedUserIds.has(user.id); return ( ); }) ))} {targetTab === "role" && (filteredRoles.length === 0 ? (
{t("hosts.sharing.noMatches")}
) : ( filteredRoles.map((role) => { const isSelected = selectedRoleIds.has(role.id); return ( ); }) ))}
{t("hosts.sharing.permissionLevelLabel")} setPermissionLevel(e.target.value as CredentialPermissionLevel) } className="h-8 w-full px-2.5 text-xs border border-border bg-background hover:bg-muted/40 transition-colors" > {PERMISSION_LEVELS.map((level) => ( ))}
{EXPIRY_PRESETS.map((preset) => ( setExpiryPreset(preset.key)} > {expiryPreset === preset.key ? ( ) : ( )} {t(`hosts.sharing.expiry.${preset.key}`)} ))}

{t(LEVEL_KEYS[permissionLevel].description)}

{expiryPreset === "custom" && ( setCustomHours(e.target.value)} className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" /> )}
{t("hosts.sharing.currentAccess")} {accessList.length > 0 && ( ({accessList.length}) )}
{accessList.length === 0 && (
{t("credentials.share.noAccessEntries")}
)} {accessList.map((record) => { const expired = record.expiresAt && new Date(record.expiresAt) < new Date(); const recordLevel = levelOf(record); return (
{record.targetType === "user" ? ( ) : ( )} {record.username ?? record.roleDisplayName ?? record.roleName ?? record.userId ?? record.roleId}
{PERMISSION_LEVELS.map((level) => ( handleLevelChange(record, level)} > {recordLevel === level ? ( ) : ( )} {t(LEVEL_KEYS[level].label)} ))}
{t("hosts.sharing.grantedBy")}:{" "} {record.grantedByUsername ?? "?"} {t("hosts.sharing.expires")}:{" "} {expired ? ( {t("hosts.sharing.expired")} ) : record.expiresAt ? ( {new Date(record.expiresAt).toLocaleString()} ) : ( {t("hosts.sharing.never")} )}
); })}
); }