mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
feat: share credentials with users and roles, inherit data on account deletion (#1342)
* feat: share credentials with users and roles, inherit data on account deletion Credentials can be shared at "use" or "manage" level. Recipients get a copy re-encrypted under their own data key (shared_credential_secrets), kept in step with the owner's row through the same lifecycle hooks as shared host secrets. One gate, findUsableCredential(), replaces the private-namespace lookups so a shared credential works wherever a private one does. Deleting a user now hands their hosts and credentials to a successor (the deleting admin by default) instead of revoking everything they shared. * fix: harden credential ownership transfer
This commit is contained in:
@@ -356,6 +356,49 @@ export async function shareSnippetFolder(
|
||||
}
|
||||
}
|
||||
|
||||
export type CredentialPermissionLevel = "use" | "manage";
|
||||
|
||||
export async function shareCredential(
|
||||
credentialId: number,
|
||||
targets: ShareTarget[],
|
||||
permissionLevel: CredentialPermissionLevel,
|
||||
durationHours?: number,
|
||||
): Promise<{ success: boolean; expiresAt: string | null }> {
|
||||
try {
|
||||
const response = await rbacApi.post(
|
||||
`/rbac/credential/${credentialId}/share`,
|
||||
{ targets, permissionLevel, durationHours },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "share credential");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCredentialAccess(
|
||||
credentialId: number,
|
||||
): Promise<{ access: AccessRecord[] }> {
|
||||
try {
|
||||
const response = await rbacApi.get(
|
||||
`/rbac/credential/${credentialId}/access`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get credential access");
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeCredentialAccess(
|
||||
credentialId: number,
|
||||
accessId: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await rbacApi.delete(`/rbac/credential/${credentialId}/access/${accessId}`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "revoke credential access");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSnippetAccess(
|
||||
snippetId: number,
|
||||
): Promise<{ accessList: AccessRecord[] }> {
|
||||
|
||||
@@ -154,10 +154,11 @@ export async function removeAdminStatus(
|
||||
|
||||
export async function deleteUser(
|
||||
username: string,
|
||||
successorUserId?: string | "none",
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.delete("/users/delete-user", {
|
||||
data: { username },
|
||||
data: { username, successorUserId },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
|
||||
@@ -114,6 +114,32 @@
|
||||
"cloneCredentialAction": "Clone credential",
|
||||
"clonedCredential": "Cloned {{name}}",
|
||||
"clonedCredentialName": "{{name}} (copy)",
|
||||
"shareCredentialAction": "Share credential",
|
||||
"sharedBadge": "shared",
|
||||
"sharedBy": "Shared by {{owner}}",
|
||||
"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"
|
||||
},
|
||||
"deleteCredentialAction": "Delete credential",
|
||||
"editCredentialAction": "Edit credential",
|
||||
"failedToCloneCredential": "Failed to clone credential",
|
||||
@@ -3653,6 +3679,10 @@
|
||||
"snippetDeletedSuccess": "Snippet deleted",
|
||||
"snippetDeleteFailed": "Failed to delete snippet",
|
||||
"noSessionsForUser": "No active sessions",
|
||||
"deleteSuccessorLabel": "Hand over hosts and credentials to",
|
||||
"deleteSuccessorMe": "Me (the deleting admin)",
|
||||
"deleteSuccessorNone": "Nobody — delete them",
|
||||
"deleteSuccessorDesc": "Everything this user shared keeps working under the new owner; choosing nobody removes their hosts, credentials and shares.",
|
||||
"deleteUserDangerDesc": "Permanently delete {{username}} and all of their data (hosts, credentials, snippets, history). This cannot be undone.",
|
||||
"deleteUserConfirm": "Permanently delete {{username}} and all of their data?",
|
||||
"deleteUserAdminBlocked": "Remove admin status before deleting this user.",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getUserList } from "@/main-axios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -118,6 +119,23 @@ export function AdminUserManagePanel({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<ManageTabId>("account");
|
||||
// Who inherits the hosts and credentials when this account goes:
|
||||
// the deleting admin by default, another user, or nobody.
|
||||
const [successor, setSuccessor] = useState<"me" | "none" | string>("me");
|
||||
const [successorOptions, setSuccessorOptions] = useState<
|
||||
Array<{ id: string; username: string }>
|
||||
>([]);
|
||||
useEffect(() => {
|
||||
getUserList()
|
||||
.then((r) =>
|
||||
setSuccessorOptions(
|
||||
r.users
|
||||
.filter((u) => u.userId !== user.id)
|
||||
.map((u) => ({ id: u.userId, username: u.username })),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
}, [user.id]);
|
||||
const [editor, setEditor] = useState<EditorState>(null);
|
||||
const [editorTab, setEditorTab] = useState("general");
|
||||
const [editorProtocols, setEditorProtocols] = useState({
|
||||
@@ -332,7 +350,8 @@ export function AdminUserManagePanel({
|
||||
async function handleDeleteUser() {
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
await deleteUser(user.username);
|
||||
if (successor === "me") await deleteUser(user.username);
|
||||
else await deleteUser(user.username, successor);
|
||||
toast.success(t("admin.deleteUserSuccess", { username: user.username }));
|
||||
onUserDeleted();
|
||||
} catch (e) {
|
||||
@@ -1209,6 +1228,25 @@ export function AdminUserManagePanel({
|
||||
username: user.username,
|
||||
})}
|
||||
</span>
|
||||
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
|
||||
{t("admin.deleteSuccessorLabel")}
|
||||
<select
|
||||
value={successor}
|
||||
onChange={(e) => setSuccessor(e.target.value)}
|
||||
className="h-7 border border-border bg-background px-2 text-xs text-foreground outline-none"
|
||||
>
|
||||
<option value="me">{t("admin.deleteSuccessorMe")}</option>
|
||||
{successorOptions.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.username}
|
||||
</option>
|
||||
))}
|
||||
<option value="none">
|
||||
{t("admin.deleteSuccessorNone")}
|
||||
</option>
|
||||
</select>
|
||||
<span>{t("admin.deleteSuccessorDesc")}</span>
|
||||
</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
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 { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import {
|
||||
getCredentialAccess,
|
||||
getRoles,
|
||||
revokeCredentialAccess,
|
||||
shareCredential,
|
||||
type CredentialPermissionLevel,
|
||||
type ShareTarget,
|
||||
} from "@/api/rbac-api";
|
||||
import { getUserList, type AccessRecord, type Role } from "@/main-axios";
|
||||
import type { Credential } from "@/types/ui-types";
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
|
||||
const EXPIRY_PRESETS = [
|
||||
{ key: "never", hours: undefined },
|
||||
{ key: "oneDay", hours: 24 },
|
||||
{ key: "sevenDays", hours: 24 * 7 },
|
||||
{ key: "thirtyDays", hours: 24 * 30 },
|
||||
] as const;
|
||||
|
||||
export function CredentialShareModal({
|
||||
credential,
|
||||
onClose,
|
||||
}: {
|
||||
credential: Credential | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<"user" | "role">("user");
|
||||
const [search, setSearch] = useState("");
|
||||
const [users, setUsers] = useState<Array<{ id: string; username: string }>>(
|
||||
[],
|
||||
);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [selectedUsers, setSelectedUsers] = useState<Set<string>>(new Set());
|
||||
const [selectedRoles, setSelectedRoles] = useState<Set<number>>(new Set());
|
||||
const [level, setLevel] = useState<CredentialPermissionLevel>("use");
|
||||
const [expiry, setExpiry] =
|
||||
useState<(typeof EXPIRY_PRESETS)[number]["key"]>("never");
|
||||
const [access, setAccess] = useState<AccessRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const credentialId = credential ? Number(credential.id) : null;
|
||||
|
||||
const loadAccess = useCallback(async () => {
|
||||
if (!credentialId) return;
|
||||
try {
|
||||
setAccess((await getCredentialAccess(credentialId)).access);
|
||||
} catch {
|
||||
setAccess([]);
|
||||
}
|
||||
}, [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]);
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
async function handleShare() {
|
||||
if (!credentialId) return;
|
||||
const targets: ShareTarget[] = [
|
||||
...Array.from(selectedUsers, (id) => ({ type: "user" as const, id })),
|
||||
...Array.from(selectedRoles, (id) => ({ type: "role" as const, id })),
|
||||
];
|
||||
if (targets.length === 0) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const preset = EXPIRY_PRESETS.find((p) => p.key === expiry);
|
||||
await shareCredential(credentialId, targets, level, preset?.hours);
|
||||
toast.success(t("credentials.share.shared"));
|
||||
setSelectedUsers(new Set());
|
||||
setSelectedRoles(new Set());
|
||||
await loadAccess();
|
||||
} catch (e) {
|
||||
toast.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(entry: AccessRecord) {
|
||||
if (!credentialId) return;
|
||||
try {
|
||||
await revokeCredentialAccess(credentialId, entry.id);
|
||||
await loadAccess();
|
||||
} catch (e) {
|
||||
toast.error(getErrorMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = <T,>(set: Set<T>, value: T): Set<T> => {
|
||||
const next = new Set(set);
|
||||
if (next.has(value)) next.delete(value);
|
||||
else next.add(value);
|
||||
return next;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={!!credential} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("credentials.share.title", { name: credential?.name ?? "" })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("credentials.share.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-6">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-1">
|
||||
{(["user", "role"] as const).map((kind) => (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
onClick={() => setTab(kind)}
|
||||
className={`flex-1 flex items-center justify-center gap-1 py-1 text-[10px] font-semibold border ${
|
||||
tab === kind
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{kind === "user" ? (
|
||||
<UserRound className="size-3" />
|
||||
) : (
|
||||
<Users className="size-3" />
|
||||
)}
|
||||
{t(
|
||||
kind === "user"
|
||||
? "credentials.share.users"
|
||||
: "credentials.share.roles",
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
placeholder={t("credentials.share.searchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
|
||||
{tab === "user"
|
||||
? filteredUsers.map((u) => (
|
||||
<label
|
||||
key={u.id}
|
||||
className="flex items-center gap-2 text-xs px-1 py-0.5 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedUsers.has(u.id)}
|
||||
onChange={() =>
|
||||
setSelectedUsers((s) => toggle(s, u.id))
|
||||
}
|
||||
/>
|
||||
{u.username}
|
||||
</label>
|
||||
))
|
||||
: filteredRoles.map((r) => (
|
||||
<label
|
||||
key={r.id}
|
||||
className="flex items-center gap-2 text-xs px-1 py-0.5 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRoles.has(r.id)}
|
||||
onChange={() =>
|
||||
setSelectedRoles((s) => toggle(s, r.id))
|
||||
}
|
||||
/>
|
||||
{r.displayName || r.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("credentials.share.permission")}
|
||||
</span>
|
||||
<select
|
||||
value={level}
|
||||
onChange={(e) =>
|
||||
setLevel(e.target.value as CredentialPermissionLevel)
|
||||
}
|
||||
className="h-8 border border-border bg-background px-2 text-xs outline-none"
|
||||
>
|
||||
<option value="use">{t("credentials.share.levelUse")}</option>
|
||||
<option value="manage">
|
||||
{t("credentials.share.levelManage")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("credentials.share.expires")}
|
||||
</span>
|
||||
<select
|
||||
value={expiry}
|
||||
onChange={(e) => setExpiry(e.target.value as typeof expiry)}
|
||||
className="h-8 border border-border bg-background px-2 text-xs outline-none"
|
||||
>
|
||||
{EXPIRY_PRESETS.map((p) => (
|
||||
<option key={p.key} value={p.key}>
|
||||
{t(`credentials.share.expiry.${p.key}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t(
|
||||
level === "manage"
|
||||
? "credentials.share.levelManageDesc"
|
||||
: "credentials.share.levelUseDesc",
|
||||
)}
|
||||
</p>
|
||||
|
||||
{access.length > 0 && (
|
||||
<div className="flex flex-col gap-1 border-t border-border pt-2">
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("credentials.share.currentAccess")}
|
||||
</span>
|
||||
{access.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-center gap-2 text-xs px-1 py-0.5"
|
||||
>
|
||||
{entry.targetType === "role" ? (
|
||||
<Users className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<UserRound className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
<span className="flex-1 truncate">
|
||||
{entry.targetType === "role"
|
||||
? entry.roleDisplayName || entry.roleName
|
||||
: entry.username}
|
||||
</span>
|
||||
<span className="text-[10px] uppercase text-muted-foreground">
|
||||
{entry.permissionLevel}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => void handleRevoke(entry)}
|
||||
title={t("credentials.share.revoke")}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleShare()}
|
||||
disabled={
|
||||
saving || (selectedUsers.size === 0 && selectedRoles.size === 0)
|
||||
}
|
||||
>
|
||||
{saving && <Loader2 className="size-3.5 mr-1 animate-spin" />}
|
||||
{t("credentials.share.shareButton")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import React, {
|
||||
type MutableRefObject,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CredentialShareModal } from "./CredentialShareModal";
|
||||
|
||||
import { Button } from "@/components/button";
|
||||
import { ArrowLeft, ChevronDown, Search, X } from "lucide-react";
|
||||
@@ -73,6 +74,9 @@ export function HostManager({
|
||||
} = {}) {
|
||||
const { t } = useTranslation();
|
||||
const [editingHost, setEditingHost] = useState<Host | "new" | null>(null);
|
||||
const [shareCredential, setShareCredential] = useState<Credential | null>(
|
||||
null,
|
||||
);
|
||||
const [editingCredential, setEditingCredential] = useState<
|
||||
Credential | "new" | null
|
||||
>(null);
|
||||
@@ -736,11 +740,17 @@ export function HostManager({
|
||||
onEditCredential={handleEditCredential}
|
||||
onCloneCredential={handleCloneCredential}
|
||||
onDeleteCredential={handleConfirmDeleteCredential}
|
||||
onShareCredential={setShareCredential}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CredentialShareModal
|
||||
credential={shareCredential}
|
||||
onClose={() => setShareCredential(null)}
|
||||
/>
|
||||
|
||||
{/* Confirm dialog */}
|
||||
{confirmDialog && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
||||
|
||||
@@ -13,6 +13,9 @@ type RawSSHHost = SSHHostWithStatus & {
|
||||
type HostQuickAction = Host["quickActions"][number];
|
||||
type HostJumpHost = NonNullable<Host["jumpHosts"]>[number];
|
||||
type RawCredential = {
|
||||
isShared?: boolean;
|
||||
ownerUsername?: string | null;
|
||||
permissionLevel?: "use" | "manage";
|
||||
id: number | string;
|
||||
name: string;
|
||||
username: string;
|
||||
@@ -192,5 +195,8 @@ export function mapCredentials(res: unknown): Credential[] {
|
||||
pin: c.pin ?? false,
|
||||
sortOrder: c.sortOrder ?? null,
|
||||
certPublicKey: c.certPublicKey ?? undefined,
|
||||
isShared: c.isShared ?? false,
|
||||
ownerUsername: c.ownerUsername ?? null,
|
||||
permissionLevel: c.permissionLevel,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Pencil,
|
||||
Pin,
|
||||
Trash2,
|
||||
Share2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -71,6 +72,7 @@ export function CredentialItem({
|
||||
onEdit,
|
||||
onClone,
|
||||
onDelete,
|
||||
onShare,
|
||||
}: {
|
||||
cred: Credential;
|
||||
usedByCount?: number;
|
||||
@@ -104,6 +106,7 @@ export function CredentialItem({
|
||||
onEdit: () => void;
|
||||
onClone: () => void;
|
||||
onDelete: () => void;
|
||||
onShare?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const reorderEdge = isReorderHovered ? reorderHoverEdge : null;
|
||||
@@ -158,38 +161,61 @@ export function CredentialItem({
|
||||
</>
|
||||
) : null;
|
||||
|
||||
// A recipient sees only what their grant allows: "manage" may edit and
|
||||
// re-share, "use" may only use. Cloning and deleting stay with the owner.
|
||||
const canEdit = !cred.isShared || cred.permissionLevel === "manage";
|
||||
const canShare =
|
||||
!!onShare && (!cred.isShared || cred.permissionLevel === "manage");
|
||||
const managementButtons = (
|
||||
<>
|
||||
<button
|
||||
title={t("credentials.editCredentialAction")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
className={trayButtonClass}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
title={t("credentials.cloneCredentialAction")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClone();
|
||||
}}
|
||||
className={trayButtonClass}
|
||||
>
|
||||
<CopyPlus className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
title={t("credentials.deleteCredentialAction")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className={`${trayButtonClass} hover:text-destructive`}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button
|
||||
title={t("credentials.editCredentialAction")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
className={trayButtonClass}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{canShare && (
|
||||
<button
|
||||
title={t("credentials.shareCredentialAction")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onShare?.();
|
||||
}}
|
||||
className={trayButtonClass}
|
||||
>
|
||||
<Share2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{!cred.isShared && (
|
||||
<>
|
||||
<button
|
||||
title={t("credentials.cloneCredentialAction")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClone();
|
||||
}}
|
||||
className={trayButtonClass}
|
||||
>
|
||||
<CopyPlus className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
title={t("credentials.deleteCredentialAction")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className={`${trayButtonClass} hover:text-destructive`}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -265,6 +291,17 @@ export function CredentialItem({
|
||||
className={`${tokens.nameTextSize} font-semibold truncate text-foreground leading-none tracking-tight`}
|
||||
>
|
||||
{cred.name}
|
||||
{cred.isShared && (
|
||||
<span
|
||||
className="ml-1 inline-flex items-center gap-0.5 text-[9px] uppercase text-accent-brand/80"
|
||||
title={t("credentials.sharedBy", {
|
||||
owner: cred.ownerUsername ?? "",
|
||||
})}
|
||||
>
|
||||
<Share2 className="size-2.5" />
|
||||
{t("credentials.sharedBadge")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[9px] px-1 py-px font-bold border leading-none shrink-0 ${isKey ? "border-accent-brand/30 text-accent-brand" : "border-border/60 text-muted-foreground/60"}`}
|
||||
|
||||
@@ -33,6 +33,7 @@ export function CredentialSidebarTree({
|
||||
onEditCredential,
|
||||
onCloneCredential,
|
||||
onDeleteCredential,
|
||||
onShareCredential,
|
||||
usedByCounts,
|
||||
termixIdLinkedIds,
|
||||
query = "",
|
||||
@@ -54,6 +55,7 @@ export function CredentialSidebarTree({
|
||||
onEditCredential: (cred: Credential) => void;
|
||||
onCloneCredential: (cred: Credential) => void;
|
||||
onDeleteCredential: (cred: Credential) => void;
|
||||
onShareCredential?: (cred: Credential) => void;
|
||||
usedByCounts?: Map<string, number>;
|
||||
termixIdLinkedIds?: Set<number>;
|
||||
query?: string;
|
||||
@@ -438,6 +440,7 @@ export function CredentialSidebarTree({
|
||||
onEdit={() => onEditCredential(item)}
|
||||
onClone={() => onCloneCredential(item)}
|
||||
onDelete={() => onDeleteCredential(item)}
|
||||
onShare={() => onShareCredential?.(item)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ const api = vi.hoisted(() => ({
|
||||
createApiKey: vi.fn(async () => ({})),
|
||||
deleteApiKey: vi.fn(async () => ({})),
|
||||
deleteUser: vi.fn(async () => ({})),
|
||||
getUserList: vi.fn(async () => ({ users: [] })),
|
||||
getUserRoles: vi.fn(async () => ({ roles: [] as unknown[] })),
|
||||
assignRoleToUser: vi.fn(async () => ({})),
|
||||
removeRoleFromUser: vi.fn(async () => ({})),
|
||||
|
||||
Reference in New Issue
Block a user