mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 10:46:51 +00:00
fix: harden stack file explorer operations (#1028)
* fix: harden stack file explorer operations * fix: update Docker toolchain to Go 1.26.3 * fix: repair Dockerfile tr argument split across lines * fix: bump protobufjs to clear npm audit high-severity advisories
This commit is contained in:
@@ -50,6 +50,7 @@ interface FilePermissionsDialogProps {
|
||||
relPath: string;
|
||||
entryName: string;
|
||||
isPaid: boolean;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export function FilePermissionsDialog({
|
||||
@@ -59,6 +60,7 @@ export function FilePermissionsDialog({
|
||||
relPath,
|
||||
entryName,
|
||||
isPaid,
|
||||
canEdit,
|
||||
}: FilePermissionsDialogProps) {
|
||||
const [mode, setMode] = useState<number>(0o644);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -101,6 +103,7 @@ export function FilePermissionsDialog({
|
||||
};
|
||||
|
||||
const octal = mode.toString(8).padStart(3, '0');
|
||||
const canModify = isPaid && canEdit;
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleClose} size="sm">
|
||||
@@ -138,15 +141,15 @@ export function FilePermissionsDialog({
|
||||
<button
|
||||
key={bit.label}
|
||||
type="button"
|
||||
disabled={!isPaid || saving}
|
||||
disabled={!canModify || saving}
|
||||
onClick={() => setMode((m) => toggleBit(m, totalShift))}
|
||||
className={cn(
|
||||
'mx-auto flex h-7 w-7 items-center justify-center rounded-md border text-xs font-mono transition-colors',
|
||||
checked
|
||||
? 'border-primary/60 bg-primary/10 text-primary'
|
||||
: 'border-border bg-muted/30 text-muted-foreground',
|
||||
isPaid && !saving && 'hover:border-primary/50 cursor-pointer',
|
||||
(!isPaid || saving) && 'opacity-50 cursor-not-allowed'
|
||||
canModify && !saving && 'hover:border-primary/50 cursor-pointer',
|
||||
(!canModify || saving) && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
aria-label={`${cat.label} ${bit.label} ${checked ? 'on' : 'off'}`}
|
||||
>
|
||||
@@ -169,11 +172,11 @@ export function FilePermissionsDialog({
|
||||
<ModalFooter
|
||||
secondary={
|
||||
<Button variant="outline" size="sm" onClick={() => handleClose(false)} disabled={saving}>
|
||||
{isPaid ? 'Cancel' : 'Close'}
|
||||
{canModify ? 'Cancel' : 'Close'}
|
||||
</Button>
|
||||
}
|
||||
primary={
|
||||
isPaid ? (
|
||||
canModify ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleSave()}
|
||||
|
||||
@@ -35,6 +35,7 @@ export function FileTreeContextMenu({
|
||||
children,
|
||||
}: FileTreeContextMenuProps) {
|
||||
const isDir = entry.type === 'directory';
|
||||
const canWrite = canEdit && isPaid;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
@@ -42,7 +43,7 @@ export function FileTreeContextMenu({
|
||||
<ContextMenuContent className="min-w-[180px]">
|
||||
{isDir ? (
|
||||
<>
|
||||
{isPaid && (
|
||||
{canWrite && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
onSelect={() => onRequestNewFile(relPath)}
|
||||
@@ -59,13 +60,13 @@ export function FileTreeContextMenu({
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{canEdit && (
|
||||
{canWrite && (
|
||||
<ContextMenuItem onSelect={() => onRequestRename(relPath)}>
|
||||
<Pencil className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||||
<span>Rename</span>
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{canEdit && (
|
||||
{canWrite && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
@@ -80,7 +81,7 @@ export function FileTreeContextMenu({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{canEdit && (
|
||||
{canWrite && (
|
||||
<ContextMenuItem onSelect={() => onRequestRename(relPath)}>
|
||||
<Pencil className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||||
<span>Rename</span>
|
||||
@@ -90,7 +91,7 @@ export function FileTreeContextMenu({
|
||||
<Lock className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||||
<span>Permissions</span>
|
||||
</ContextMenuItem>
|
||||
{canEdit && (
|
||||
{canWrite && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
|
||||
@@ -9,18 +9,20 @@ const MAX_BYTES = 25 * 1024 * 1024; // 25 MB
|
||||
interface FileUploadDropzoneProps {
|
||||
stackName: string;
|
||||
currentDir: string;
|
||||
canEdit: boolean;
|
||||
onUploaded: () => void;
|
||||
}
|
||||
|
||||
export function FileUploadDropzone({
|
||||
stackName,
|
||||
currentDir,
|
||||
canEdit,
|
||||
onUploaded,
|
||||
}: FileUploadDropzoneProps) {
|
||||
const { isPaid } = useLicense();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
if (!isPaid) return null;
|
||||
if (!isPaid || !canEdit) return null;
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
if (file.size > MAX_BYTES) {
|
||||
|
||||
@@ -150,10 +150,11 @@ export function StackFileExplorer({
|
||||
<FileUploadDropzone
|
||||
stackName={stackName}
|
||||
currentDir={currentDir}
|
||||
canEdit={canEdit}
|
||||
onUploaded={refresh}
|
||||
/>
|
||||
</div>
|
||||
{isPaid && (
|
||||
{isPaid && canEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -207,16 +208,18 @@ export function StackFileExplorer({
|
||||
)}
|
||||
Download
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
data-testid="file-action-delete"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" strokeWidth={1.5} />
|
||||
Delete
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
data-testid="file-action-delete"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" strokeWidth={1.5} />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
@@ -297,6 +300,7 @@ export function StackFileExplorer({
|
||||
relPath={permissionsRelPath}
|
||||
entryName={permissionsEntryName}
|
||||
isPaid={isPaid}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { FileUploadDropzone } from '../FileUploadDropzone';
|
||||
|
||||
const licenseState = { isPaid: true };
|
||||
|
||||
vi.mock('@/context/LicenseContext', () => ({
|
||||
useLicense: () => licenseState,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/stackFilesApi', () => ({
|
||||
uploadStackFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
loading: vi.fn(() => 'loading-id'),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('FileUploadDropzone', () => {
|
||||
beforeEach(() => {
|
||||
licenseState.isPaid = true;
|
||||
});
|
||||
|
||||
it('renders upload control for paid users with stack edit permission', () => {
|
||||
render(
|
||||
<FileUploadDropzone
|
||||
stackName="app"
|
||||
currentDir=""
|
||||
canEdit
|
||||
onUploaded={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /upload file/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides upload control when the user cannot edit the stack', () => {
|
||||
render(
|
||||
<FileUploadDropzone
|
||||
stackName="app"
|
||||
currentDir=""
|
||||
canEdit={false}
|
||||
onUploaded={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /upload file/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides upload control on Community tier', () => {
|
||||
licenseState.isPaid = false;
|
||||
|
||||
render(
|
||||
<FileUploadDropzone
|
||||
stackName="app"
|
||||
currentDir=""
|
||||
canEdit
|
||||
onUploaded={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /upload file/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user