diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index d6eec9e60..d7f70d58a 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -51,25 +51,13 @@ "share": { "title": "Share \"{{name}}\"", "description": "Recipients get their own encrypted copy of the secrets. It is refreshed whenever the credential changes and removed when the share is revoked.", - "users": "Users", - "roles": "Roles", - "searchPlaceholder": "Search...", - "permission": "Permission", "levelUse": "Use", "levelManage": "Manage", "levelUseDesc": "Use: attach the credential to hosts and connect. The secret itself stays hidden.", "levelManageDesc": "Manage: also edit the credential and share it with others.", - "expires": "Expires", - "expiry": { - "never": "Never", - "oneDay": "1 day", - "sevenDays": "7 days", - "thirtyDays": "30 days" - }, - "currentAccess": "Shared with", - "revoke": "Revoke", - "shared": "Credential shared", - "shareButton": "Share" + "noAccessEntries": "This credential has not been shared yet", + "accessRevoked": "Access revoked", + "shared": "Credential shared" }, "deleteCredentialAction": "Delete credential", "editCredentialAction": "Edit credential", diff --git a/src/ui/sidebar/CredentialShareModal.tsx b/src/ui/sidebar/CredentialShareModal.tsx index 33b55bb17..0830a3c44 100644 --- a/src/ui/sidebar/CredentialShareModal.tsx +++ b/src/ui/sidebar/CredentialShareModal.tsx @@ -1,17 +1,26 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import { Loader2, Trash2, Users, UserRound } from "lucide-react"; +import { + ArrowLeft, + Check, + ListChecks, + Search, + Share2, + Shield, + User, + Users, + X, +} from "lucide-react"; import { Button } from "@/components/button"; import { Input } from "@/components/input"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/dialog"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/dropdown-menu"; +import { Select2 } from "@/components/select2"; import { getCredentialAccess, getRoles, @@ -20,17 +29,41 @@ import { type CredentialPermissionLevel, type ShareTarget, } from "@/api/rbac-api"; -import { getUserList, type AccessRecord, type Role } from "@/main-axios"; +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: undefined }, + { 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, @@ -39,285 +72,504 @@ export function CredentialShareModal({ onClose: () => void; }) { const { t } = useTranslation(); - const [tab, setTab] = useState<"user" | "role">("user"); + const [targetTab, setTargetTab] = useState<"user" | "role">("user"); const [search, setSearch] = useState(""); - const [users, setUsers] = 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 [roles, setRoles] = useState([]); - const [selectedUsers, setSelectedUsers] = useState>(new Set()); - const [selectedRoles, setSelectedRoles] = useState>(new Set()); - const [level, setLevel] = useState("use"); - const [expiry, setExpiry] = - useState<(typeof EXPIRY_PRESETS)[number]["key"]>("never"); - const [access, setAccess] = useState([]); - const [loading, setLoading] = useState(false); - const [saving, setSaving] = useState(false); + 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 loadAccess = useCallback(async () => { + const refreshAccessList = useCallback(async () => { if (!credentialId) return; - try { - setAccess((await getCredentialAccess(credentialId)).access); - } catch { - setAccess([]); - } + setAccessList((await getCredentialAccess(credentialId)).access ?? []); }, [credentialId]); useEffect(() => { - if (!credential) return; - setSelectedUsers(new Set()); - setSelectedRoles(new Set()); setSearch(""); - setLoading(true); - Promise.all([ - getUserList().catch(() => ({ users: [] })), - getRoles().catch(() => ({ roles: [] as Role[] })), - loadAccess(), - ]) - .then(([userResult, roleResult]) => { - setUsers( - userResult.users.map((u) => ({ id: u.userId, username: u.username })), - ); - setRoles(roleResult.roles); - }) - .finally(() => setLoading(false)); - }, [credential, loadAccess]); + setSelectedUserIds(new Set()); + setSelectedRoleIds(new Set()); + setPermissionLevel("use"); + setExpiryPreset("never"); + setCustomHours(""); + setTargetTab("user"); + setAccessList([]); + setLoadError(false); + if (!credentialId) return; - const filteredUsers = useMemo( - () => - users.filter((u) => - u.username.toLowerCase().includes(search.toLowerCase()), - ), - [users, search], - ); - const filteredRoles = useMemo( - () => - roles.filter((r) => - (r.displayName || r.name).toLowerCase().includes(search.toLowerCase()), - ), - [roles, search], - ); + 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) return; + if (!credentialId || selectedCount === 0) return; const targets: ShareTarget[] = [ - ...Array.from(selectedUsers, (id) => ({ type: "user" as const, id })), - ...Array.from(selectedRoles, (id) => ({ type: "role" as const, id })), + ...Array.from(selectedUserIds, (id) => ({ type: "user" as const, id })), + ...Array.from(selectedRoleIds, (id) => ({ type: "role" as const, id })), ]; - if (targets.length === 0) return; - setSaving(true); + setSubmitting(true); try { - const preset = EXPIRY_PRESETS.find((p) => p.key === expiry); - await shareCredential(credentialId, targets, level, preset?.hours); + await shareCredential( + credentialId, + targets, + permissionLevel, + durationHours, + ); + await refreshAccessList(); + setSelectedUserIds(new Set()); + setSelectedRoleIds(new Set()); toast.success(t("credentials.share.shared")); - setSelectedUsers(new Set()); - setSelectedRoles(new Set()); - await loadAccess(); } catch (e) { toast.error(getErrorMessage(e)); } finally { - setSaving(false); + setSubmitting(false); } } - async function handleRevoke(entry: AccessRecord) { + // 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, entry.id); - await loadAccess(); + 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)); } } - const toggle = (set: Set, value: T): Set => { - const next = new Set(set); - if (next.has(value)) next.delete(value); - else next.add(value); - return next; - }; + if (!credential) return null; return ( - !open && onClose()}> - - - - {t("credentials.share.title", { name: credential?.name ?? "" })} - - - {t("credentials.share.description")} - - +
+ - {loading ? ( -
- + {loadError && ( +
+ +
{t("hosts.sharing.loadError")}
+
+ )} + +
+
+
+ + {t("hosts.sharing.shareWithSection")}
- ) : ( -
-
- {(["user", "role"] as const).map((kind) => ( - - ))} -
- setSearch(e.target.value)} - /> -
- {tab === "user" - ? filteredUsers.map((u) => ( - - )) - : filteredRoles.map((r) => ( - - ))} -
- -
-
- - {t("credentials.share.permission")} - - -
-
- - {t("credentials.share.expires")} - - -
-
-

- {t( - level === "manage" - ? "credentials.share.levelManageDesc" - : "credentials.share.levelUseDesc", - )} -

- - {access.length > 0 && ( -
- - {t("credentials.share.currentAccess")} - - {access.map((entry) => ( -
- {entry.targetType === "role" ? ( - - ) : ( - - )} - - {entry.targetType === "role" - ? entry.roleDisplayName || entry.roleName - : entry.username} - - - {entry.permissionLevel} - - -
- ))} -
- )} -
- )} - - - -
+ +

+ {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")} + + )} + +
+
+ ); + })} +
+
+ ); }