feat: harden file sharing and integrity
This commit is contained in:
@@ -1,4 +1,15 @@
|
||||
import { ApiError, apiFetch, apiUrl, authHeaders, csrfToken, type ApiSettings, type DeltaDeletedItem, type FilesManagedFileLinkTarget } from "@govoplan/core-webui";
|
||||
import {
|
||||
ApiError,
|
||||
apiFetch,
|
||||
apiReferenceOptionProvider,
|
||||
apiUrl,
|
||||
authHeaders,
|
||||
csrfToken,
|
||||
type ApiSettings,
|
||||
type DeltaDeletedItem,
|
||||
type FilesManagedFileLinkTarget,
|
||||
type ReferenceOptionProvider
|
||||
} from "@govoplan/core-webui";
|
||||
export { fetchResourceAccessExplanation } from "@govoplan/core-webui";
|
||||
export type {
|
||||
AccessDecisionProvenanceItem,
|
||||
@@ -24,11 +35,16 @@ export type FileSpace = {
|
||||
|
||||
export type FileShare = {
|
||||
id: string;
|
||||
file_asset_id: string;
|
||||
target_type: string;
|
||||
target_id: string;
|
||||
permission: string;
|
||||
created_by_user_id?: string | null;
|
||||
created_at: string;
|
||||
expires_at?: string | null;
|
||||
revoked_at?: string | null;
|
||||
revoked_by_user_id?: string | null;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export type FileSourceProvenance = {
|
||||
@@ -593,6 +609,12 @@ export function bulkDeleteFiles(settings: ApiSettings, fileIds: string[]): Promi
|
||||
}
|
||||
|
||||
export type FileBulkShareResponse = {shares: FileShare[];shared_count: number;};
|
||||
export type FileSharePayload = {
|
||||
target_type: "user" | "group" | "tenant" | "campaign";
|
||||
target_id: string;
|
||||
permission: "read" | "write" | "manage";
|
||||
expires_at?: string | null;
|
||||
};
|
||||
|
||||
export function virtualFolderResourceId(options: {tenantId: string;ownerType: "user" | "group";ownerId: string;path: string;}): string {
|
||||
return `virtual-folder:v1:${options.tenantId}:${options.ownerType}:${options.ownerId}:${base64Url(options.path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""))}`;
|
||||
@@ -612,6 +634,54 @@ export function shareFilesWithTarget(settings: ApiSettings, fileIds: string[], t
|
||||
});
|
||||
}
|
||||
|
||||
export function listFileShares(
|
||||
settings: ApiSettings,
|
||||
fileId: string,
|
||||
options: { includeInactive?: boolean } = {}
|
||||
): Promise<{ shares: FileShare[] }> {
|
||||
const suffix = options.includeInactive ? "?include_inactive=true" : "";
|
||||
return apiFetch<{ shares: FileShare[] }>(
|
||||
settings,
|
||||
`/api/v1/files/${encodeURIComponent(fileId)}/shares${suffix}`
|
||||
);
|
||||
}
|
||||
|
||||
export function createFileShare(
|
||||
settings: ApiSettings,
|
||||
fileId: string,
|
||||
payload: FileSharePayload
|
||||
): Promise<FileShare> {
|
||||
return apiFetch<FileShare>(
|
||||
settings,
|
||||
`/api/v1/files/${encodeURIComponent(fileId)}/shares`,
|
||||
{ method: "POST", body: JSON.stringify(payload) }
|
||||
);
|
||||
}
|
||||
|
||||
export function revokeFileShare(
|
||||
settings: ApiSettings,
|
||||
fileId: string,
|
||||
shareId: string
|
||||
): Promise<FileShare> {
|
||||
return apiFetch<FileShare>(
|
||||
settings,
|
||||
`/api/v1/files/${encodeURIComponent(fileId)}/shares/${encodeURIComponent(shareId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
}
|
||||
|
||||
export function fileShareTargetProvider(
|
||||
settings: ApiSettings,
|
||||
fileId: string,
|
||||
targetType: "user" | "group"
|
||||
): ReferenceOptionProvider {
|
||||
return apiReferenceOptionProvider(
|
||||
settings,
|
||||
`/api/v1/files/${encodeURIComponent(fileId)}/share-target-options`,
|
||||
{ target_type: targetType }
|
||||
);
|
||||
}
|
||||
|
||||
export function evaluateFileConnectorPolicy(
|
||||
settings: ApiSettings,
|
||||
payload: {source_provenance: FileSourceProvenance;operation?: string;policy_sources?: FileConnectorPolicySource[];})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react";
|
||||
import { ArrowUp, ChevronRight, Copy, Download, File, Folder, Home, KeyRound, Link2, ListFilter, MoveRight, Plus, RefreshCw, Search, Trash2, UploadCloud } from "lucide-react";
|
||||
import { ArrowUp, ChevronRight, Copy, Download, File, Folder, Home, KeyRound, Link2, ListFilter, MoveRight, Plus, RefreshCw, Search, Share2, Trash2, UploadCloud } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
"../../api/files";
|
||||
import { EMPTY_FILES, EMPTY_FOLDERS, EMPTY_SPACES, INTERNAL_DRAG_TYPE } from "./constants";
|
||||
import { FileConflictDialog, FileContextMenu, FileDialog, FolderTree, RenamePreviewList, TransferFolderSelector } from "./components/FileManagerComponents";
|
||||
import FileShareDialog from "./components/FileShareDialog";
|
||||
import type {
|
||||
ContextMenuState,
|
||||
DialogKind,
|
||||
@@ -106,6 +107,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
const canUpload = hasScope(auth, "files:upload");
|
||||
const canOrganize = hasScope(auth, "files:organize");
|
||||
const canDelete = hasScope(auth, "files:delete");
|
||||
const canShare = hasScope(auth, "files:file:share");
|
||||
const [spaces, setSpaces] = useState<FileSpace[]>(EMPTY_SPACES);
|
||||
const [activeSpaceId, setActiveSpaceId] = useState("");
|
||||
const activeSpace = spaces.find((space) => space.id === activeSpaceId) ?? spaces[0] ?? null;
|
||||
@@ -154,6 +156,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
const [accessExplanationTarget, setAccessExplanationTarget] = useState<FileAccessExplanationTarget | null>(null);
|
||||
const [resourceAccessExplanation, setResourceAccessExplanation] = useState<ResourceAccessExplanationResponse | null>(null);
|
||||
const [resourceAccessLoading, setResourceAccessLoading] = useState(false);
|
||||
const [shareDialogFile, setShareDialogFile] = useState<ManagedFile | null>(null);
|
||||
const { setDragActive, internalDrag, setInternalDrag, dropTargetKey, setDropTargetKey, clearDropState: clearDragDropState } = useFileDragDropState();
|
||||
const {
|
||||
dialog,
|
||||
@@ -245,6 +248,14 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
onOpenFolder: openFolder
|
||||
});
|
||||
const selectedFiles = useMemo(() => files.filter((file) => selectedFileIds.has(file.id)), [files, selectedFileIds]);
|
||||
const shareManageableFile = useMemo(() => {
|
||||
if (!canShare || selectedFiles.length !== 1 || selectedFolderPaths.size > 0) return null;
|
||||
const file = selectedFiles[0];
|
||||
const ownsFile = file.owner_type === "user"
|
||||
? file.owner_id === auth.user.id
|
||||
: auth.groups.some((group) => group.id === file.owner_id);
|
||||
return ownsFile || hasScope(auth, "files:file:admin") ? file : null;
|
||||
}, [auth, canShare, selectedFiles, selectedFolderPaths]);
|
||||
const selectedDownloadFileIds = useMemo(() => fileIdsForSelection(files, selectedFileIds, selectedFolderPaths), [files, selectedFileIds, selectedFolderPaths]);
|
||||
const accessExplainableTarget = useMemo(
|
||||
() => accessTargetForSelection(selectedFileIds, selectedFolderPaths, activeSpaceId),
|
||||
@@ -1896,6 +1907,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
<Link2 size={16} aria-hidden="true" /> i18n:govoplan-files.add_space.e4d674d4
|
||||
</Button>
|
||||
<Button onClick={() => void downloadSelection()} disabled={busy || activeSpaceIsConnector || !canDownload || selectedDownloadFileIds.length === 0}><Download size={16} aria-hidden="true" /> {downloadLabel}</Button>
|
||||
<Button onClick={() => shareManageableFile && setShareDialogFile(shareManageableFile)} disabled={busy || activeSpaceIsConnector || !shareManageableFile}><Share2 size={16} aria-hidden="true" /> Manage shares</Button>
|
||||
<Button onClick={() => openDialog("create-folder", toolbarTarget())} disabled={busy || !activeSpace || activeSpaceIsConnector || !canOrganize}><Plus size={16} aria-hidden="true" /> i18n:govoplan-files.create_folder.97bafaba</Button>
|
||||
<Button onClick={() => openTransferDialog("move")} disabled={busy || activeSpaceIsConnector || !canOrganize || !hasSelection}><MoveRight size={16} aria-hidden="true" /> i18n:govoplan-files.move.76cdb950</Button>
|
||||
<Button onClick={() => openTransferDialog("copy")} disabled={busy || activeSpaceIsConnector || !canOrganize || !hasSelection}><Copy size={16} aria-hidden="true" /> i18n:govoplan-files.copy.af74f7c5</Button>
|
||||
@@ -2307,6 +2319,17 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut
|
||||
</FileDialog>
|
||||
}
|
||||
|
||||
{shareDialogFile &&
|
||||
<FileShareDialog
|
||||
settings={settings}
|
||||
file={shareDialogFile}
|
||||
tenantId={auth.active_tenant?.id || auth.tenant.id}
|
||||
onClose={() => setShareDialogFile(null)}
|
||||
onChanged={() => {
|
||||
if (activeSpace) void loadSpaceContents(activeSpace, { silent: true });
|
||||
}} />
|
||||
}
|
||||
|
||||
{dialog === "upload" &&
|
||||
<FileDialog title={i18nMessage("i18n:govoplan-files.upload_to_value_value.b83a34b4", { value0: activeDialogSpace?.label || "i18n:govoplan-files.files.6ce6c512", value1: activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5" })} onClose={closeDialog}>
|
||||
<FileDropZone
|
||||
@@ -2709,7 +2732,15 @@ function FileSearchRow({
|
||||
}
|
||||
|
||||
function activeCampaignShares(file: ManagedFile) {
|
||||
return (file.shares ?? []).filter((share) => share.target_type === "campaign" && !share.revoked_at);
|
||||
return (file.shares ?? []).filter((share) =>
|
||||
share.target_type === "campaign" && fileShareIsActive(share)
|
||||
);
|
||||
}
|
||||
|
||||
function fileShareIsActive(share: NonNullable<ManagedFile["shares"]>[number]): boolean {
|
||||
if (typeof share.active === "boolean") return share.active;
|
||||
return !share.revoked_at
|
||||
&& (!share.expires_at || new Date(share.expires_at).getTime() > Date.now());
|
||||
}
|
||||
|
||||
function activeCampaignShareCount(file: ManagedFile): number {
|
||||
|
||||
300
webui/src/features/files/components/FileShareDialog.tsx
Normal file
300
webui/src/features/files/components/FileShareDialog.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
DataGrid,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
ReferenceSelect,
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
type ApiSettings,
|
||||
type DataGridColumn
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
createFileShare,
|
||||
fileShareTargetProvider,
|
||||
listFileShares,
|
||||
revokeFileShare,
|
||||
type FileShare,
|
||||
type ManagedFile
|
||||
} from "../../../api/files";
|
||||
import { FileDialog } from "./FileManagerComponents";
|
||||
import { formatDate } from "../utils/fileManagerUtils";
|
||||
|
||||
type ShareTargetType = "user" | "group" | "tenant";
|
||||
type SharePermission = "read" | "write" | "manage";
|
||||
|
||||
export default function FileShareDialog({
|
||||
settings,
|
||||
file,
|
||||
tenantId,
|
||||
onClose,
|
||||
onChanged
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
file: ManagedFile;
|
||||
tenantId: string;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [shares, setShares] = useState<FileShare[]>([]);
|
||||
const [targetType, setTargetType] = useState<ShareTargetType>("user");
|
||||
const [targetId, setTargetId] = useState("");
|
||||
const [permission, setPermission] = useState<SharePermission>("read");
|
||||
const [expiresAt, setExpiresAt] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [revokeTarget, setRevokeTarget] = useState<FileShare | null>(null);
|
||||
const targetProvider = useMemo(
|
||||
() => targetType === "tenant"
|
||||
? null
|
||||
: fileShareTargetProvider(settings, file.id, targetType),
|
||||
[file.id, settings, targetType]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [file.id]);
|
||||
|
||||
async function refresh() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await listFileShares(settings, file.id, {
|
||||
includeInactive: true
|
||||
});
|
||||
setShares(response.shares);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function grant() {
|
||||
const effectiveTargetId = targetType === "tenant" ? tenantId : targetId;
|
||||
if (!effectiveTargetId) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await createFileShare(settings, file.id, {
|
||||
target_type: targetType,
|
||||
target_id: effectiveTargetId,
|
||||
permission,
|
||||
expires_at: expiresAt ? new Date(expiresAt).toISOString() : null
|
||||
});
|
||||
setTargetId("");
|
||||
setExpiresAt("");
|
||||
await refresh();
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke() {
|
||||
if (!revokeTarget) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await revokeFileShare(settings, file.id, revokeTarget.id);
|
||||
setRevokeTarget(null);
|
||||
await refresh();
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<FileShare>[]>(() => [
|
||||
{
|
||||
id: "target",
|
||||
header: "Target",
|
||||
minWidth: 220,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (share) => `${share.target_type}: ${share.target_id}`
|
||||
},
|
||||
{
|
||||
id: "permission",
|
||||
header: "Permission",
|
||||
width: 115,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (share) => share.permission
|
||||
},
|
||||
{
|
||||
id: "created",
|
||||
header: "Created",
|
||||
minWidth: 160,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
value: (share) => formatDate(share.created_at)
|
||||
},
|
||||
{
|
||||
id: "creator",
|
||||
header: "Created by",
|
||||
minWidth: 150,
|
||||
resizable: true,
|
||||
filterable: true,
|
||||
value: (share) => share.created_by_user_id || "System"
|
||||
},
|
||||
{
|
||||
id: "expiry",
|
||||
header: "Expires",
|
||||
minWidth: 160,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
value: (share) => share.expires_at ? formatDate(share.expires_at) : "No expiry"
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
width: 115,
|
||||
sortable: true,
|
||||
value: (share) => shareStatus(share),
|
||||
render: (share) => (
|
||||
<StatusBadge
|
||||
status={share.active ? "active" : "inactive"}
|
||||
label={shareStatus(share)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 72,
|
||||
sticky: "end",
|
||||
align: "right",
|
||||
render: (share) => (
|
||||
<TableActionGroup
|
||||
minimumSlots={1}
|
||||
actions={[
|
||||
{
|
||||
id: "revoke",
|
||||
label: "Revoke share",
|
||||
icon: <Trash2 size={16} aria-hidden="true" />,
|
||||
variant: "danger",
|
||||
disabled: !share.active || busy,
|
||||
disabledReason: !share.active ? "This share is already inactive." : undefined,
|
||||
onClick: () => setRevokeTarget(share)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
], [busy]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileDialog title={`Manage shares - ${file.filename}`} onClose={() => !busy && onClose()}>
|
||||
<div className="file-share-dialog-content">
|
||||
{error ? (
|
||||
<DismissibleAlert tone="danger" resetKey={error}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
) : null}
|
||||
<div className="file-share-form">
|
||||
<FormField label="Target type">
|
||||
<select
|
||||
value={targetType}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
setTargetType(event.target.value as ShareTargetType);
|
||||
setTargetId("");
|
||||
}}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="group">Group</option>
|
||||
<option value="tenant">Entire tenant</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Target">
|
||||
{targetType === "tenant" ? (
|
||||
<input value={tenantId} disabled readOnly />
|
||||
) : targetProvider ? (
|
||||
<ReferenceSelect
|
||||
value={targetId}
|
||||
onChange={setTargetId}
|
||||
provider={targetProvider}
|
||||
disabled={busy}
|
||||
placeholder={`Select a ${targetType}`}
|
||||
aria-label={`Share target ${targetType}`}
|
||||
required
|
||||
/>
|
||||
) : null}
|
||||
</FormField>
|
||||
<FormField label="Permission">
|
||||
<select
|
||||
value={permission}
|
||||
disabled={busy}
|
||||
onChange={(event) => setPermission(event.target.value as SharePermission)}
|
||||
>
|
||||
<option value="read">Read</option>
|
||||
<option value="write">Write</option>
|
||||
<option value="manage">Manage</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Expires (optional)">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={expiresAt}
|
||||
min={minimumLocalDateTime()}
|
||||
disabled={busy}
|
||||
onChange={(event) => setExpiresAt(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy || (targetType !== "tenant" && !targetId)}
|
||||
onClick={() => void grant()}
|
||||
>
|
||||
Grant access
|
||||
</Button>
|
||||
</div>
|
||||
<DataGrid
|
||||
id={`file-${file.id}-shares`}
|
||||
rows={shares}
|
||||
columns={columns}
|
||||
getRowKey={(share) => share.id}
|
||||
emptyText={busy ? "Loading shares..." : "This file has no direct shares."}
|
||||
initialFit="container"
|
||||
/>
|
||||
<div className="button-row compact-actions align-end">
|
||||
<Button onClick={onClose} disabled={busy}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FileDialog>
|
||||
<ConfirmDialog
|
||||
open={Boolean(revokeTarget)}
|
||||
title="Revoke file share"
|
||||
message="Revoke this direct grant? Other independent grants remain in effect."
|
||||
confirmLabel="Revoke"
|
||||
cancelLabel="Cancel"
|
||||
tone="danger"
|
||||
busy={busy}
|
||||
onConfirm={() => void revoke()}
|
||||
onCancel={() => setRevokeTarget(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function shareStatus(share: FileShare): string {
|
||||
if (share.revoked_at) return "Revoked";
|
||||
if (share.expires_at && new Date(share.expires_at).getTime() <= Date.now()) {
|
||||
return "Expired";
|
||||
}
|
||||
return "Active";
|
||||
}
|
||||
|
||||
function minimumLocalDateTime(): string {
|
||||
const now = new Date(Date.now() + 60_000);
|
||||
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
@@ -1024,7 +1024,14 @@ renderPatternPreview?: FilesManagedFileChooserProps["renderPatternPreview"])
|
||||
|
||||
function isLinkedToTarget(file: ManagedFile, target?: FilesManagedFileLinkTarget): boolean {
|
||||
if (!target) return false;
|
||||
return Boolean(file.shares?.some((share) => share.target_type === target.type && share.target_id === target.id && !share.revoked_at));
|
||||
return Boolean(file.shares?.some((share) =>
|
||||
share.target_type === target.type
|
||||
&& share.target_id === target.id
|
||||
&& (typeof share.active === "boolean"
|
||||
? share.active
|
||||
: !share.revoked_at
|
||||
&& (!share.expires_at || new Date(share.expires_at).getTime() > Date.now()))
|
||||
));
|
||||
}
|
||||
|
||||
function readRememberedState(key: string): RememberedChooserState {
|
||||
|
||||
@@ -288,6 +288,35 @@
|
||||
width: min(820px, 100%);
|
||||
}
|
||||
|
||||
.file-dialog:has(.file-share-dialog-content) {
|
||||
width: min(1080px, 100%);
|
||||
}
|
||||
|
||||
.file-share-dialog-content {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.file-share-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(130px, .7fr) minmax(220px, 1.4fr) minmax(120px, .7fr) minmax(190px, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.file-share-form {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.file-share-form {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.connector-sync-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr);
|
||||
|
||||
Reference in New Issue
Block a user