feat(files): open stack file explorer to every tier (#1144)

* feat(files): open stack file explorer to every tier

Drop the `requirePaid` guard from the seven stack-file write routes
(download, upload, write-content, delete, mkdir, rename, chmod) and
remove every matching `isPaid` check from the file-explorer frontend.
Stack edit permission (RBAC) continues to gate every write end-to-end.

The file explorer is the primary way a user touches a stack's on-disk
surface; gating it behind a paid tier conflicted with the principle
that Community covers single user-initiated actions while paid tiers
add automation and governance.

* docs(files): treat download as a read action, not a write

Download has no `requirePermission('stack:edit')` on the route and no
`canEdit` gate in the UI, so viewer accounts can download. Update the
top paragraph to list download under reads, and rewrite the
troubleshooting accordion to describe the actual gating (a file must be
selected) instead of asserting a role gate that does not exist.

* test(e2e): align stack-files spec with the new tier rule

The community-tier describe block asserted that the Upload control is
absent and the editor shows a `Read-only` chip; the admin-tier block
skipped on Community via `test.skip(tier !== 'paid')`. Both rules
reflected the previous gate, where writes required a paid tier.

Writes are now gated on the `stack:edit` role, not on the license tier.
Repurpose the community describe to assert that a Community admin
under a mocked community license still sees the Upload control and an
editable Save button. Drop the obsolete tier-skip in the admin describe
so upload, edit, delete, and download exercise on every tier. Update
stale comments to reference the role gate.
This commit is contained in:
Anso
2026-05-21 19:35:31 -04:00
committed by GitHub
parent 380ed6fd50
commit 535023b350
14 changed files with 137 additions and 188 deletions
@@ -49,7 +49,6 @@ interface FilePermissionsDialogProps {
stackName: string;
relPath: string;
entryName: string;
isPaid: boolean;
canEdit: boolean;
}
@@ -59,7 +58,6 @@ export function FilePermissionsDialog({
stackName,
relPath,
entryName,
isPaid,
canEdit,
}: FilePermissionsDialogProps) {
const [mode, setMode] = useState<number>(0o644);
@@ -103,7 +101,7 @@ export function FilePermissionsDialog({
};
const octal = mode.toString(8).padStart(3, '0');
const canModify = isPaid && canEdit;
const canModify = canEdit;
return (
<Modal open={open} onOpenChange={handleClose} size="sm">
@@ -18,7 +18,6 @@ interface FileTreeProps {
onNavigateToEnv?: () => void;
// Context menu wiring
canEdit?: boolean;
isPaid?: boolean;
onContextMenuRename?: (relPath: string) => void;
onContextMenuNewFile?: (dirRelPath: string) => void;
onContextMenuNewFolder?: (dirRelPath: string) => void;
@@ -39,7 +38,6 @@ export function FileTree({
onNavigateToCompose,
onNavigateToEnv,
canEdit = false,
isPaid = false,
onContextMenuRename = () => undefined,
onContextMenuNewFile = () => undefined,
onContextMenuNewFolder = () => undefined,
@@ -173,7 +171,6 @@ export function FileTree({
}
}}
canEdit={canEdit}
isPaid={isPaid}
onContextMenuRename={onContextMenuRename}
onContextMenuNewFile={onContextMenuNewFile}
onContextMenuNewFolder={onContextMenuNewFolder}
@@ -13,7 +13,6 @@ interface FileTreeContextMenuProps {
entry: FileEntry;
relPath: string;
canEdit: boolean;
isPaid: boolean;
onRequestRename: (relPath: string) => void;
onRequestNewFile: (dirRelPath: string) => void;
onRequestNewFolder: (dirRelPath: string) => void;
@@ -26,7 +25,6 @@ export function FileTreeContextMenu({
entry,
relPath,
canEdit,
isPaid,
onRequestRename,
onRequestNewFile,
onRequestNewFolder,
@@ -35,7 +33,7 @@ export function FileTreeContextMenu({
children,
}: FileTreeContextMenuProps) {
const isDir = entry.type === 'directory';
const canWrite = canEdit && isPaid;
const canWrite = canEdit;
return (
<ContextMenu>
@@ -13,7 +13,6 @@ interface FileTreeNodeProps {
onClick: () => void;
// Context menu wiring
canEdit: boolean;
isPaid: boolean;
onContextMenuRename: (relPath: string) => void;
onContextMenuNewFile: (dirRelPath: string) => void;
onContextMenuNewFolder: (dirRelPath: string) => void;
@@ -30,7 +29,6 @@ export function FileTreeNode({
isLoading,
onClick,
canEdit,
isPaid,
onContextMenuRename,
onContextMenuNewFile,
onContextMenuNewFolder,
@@ -44,7 +42,6 @@ export function FileTreeNode({
entry={entry}
relPath={relPath}
canEdit={canEdit}
isPaid={isPaid}
onRequestRename={onContextMenuRename}
onRequestNewFile={onContextMenuNewFile}
onRequestNewFolder={onContextMenuNewFolder}
@@ -1,7 +1,6 @@
import { useRef } from 'react';
import { UploadCloud } from 'lucide-react';
import { toast } from '@/components/ui/toast-store';
import { useLicense } from '@/context/LicenseContext';
import { uploadStackFile } from '@/lib/stackFilesApi';
const MAX_BYTES = 25 * 1024 * 1024; // 25 MB
@@ -19,10 +18,9 @@ export function FileUploadDropzone({
canEdit,
onUploaded,
}: FileUploadDropzoneProps) {
const { isPaid } = useLicense();
const inputRef = useRef<HTMLInputElement>(null);
if (!isPaid || !canEdit) return null;
if (!canEdit) return null;
const handleFile = async (file: File) => {
if (file.size > MAX_BYTES) {
+14 -24
View File
@@ -4,7 +4,6 @@ import { AlertCircle, FileIcon, Download, Loader2, Save } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from '@/components/ui/toast-store';
import { useLicense } from '@/context/LicenseContext';
import { readStackFile, writeStackFile, downloadStackFile } from '@/lib/stackFilesApi';
import { extensionToLanguage } from '@/lib/monacoLanguages';
import { formatBytes } from '@/lib/utils';
@@ -27,7 +26,6 @@ interface SpecialFilePanelProps {
label: string;
stackName: string;
relPath: string;
canDownload: boolean;
}
function SpecialFilePanel({
@@ -36,12 +34,10 @@ function SpecialFilePanel({
label,
stackName,
relPath,
canDownload,
}: SpecialFilePanelProps) {
const [downloading, setDownloading] = useState(false);
const handleDownload = async () => {
if (!canDownload) return;
setDownloading(true);
try {
const res = await downloadStackFile(stackName, relPath);
@@ -72,21 +68,19 @@ function SpecialFilePanel({
<p className="font-mono text-sm text-stat-title">{filename}</p>
<p className="text-xs text-stat-subtitle">{label} &middot; {formatBytes(size)}</p>
</div>
{canDownload && (
<Button
variant="outline"
size="sm"
onClick={() => void handleDownload()}
disabled={downloading}
>
{downloading ? (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />
) : (
<Download className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
)}
Download
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => void handleDownload()}
disabled={downloading}
>
{downloading ? (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />
) : (
<Download className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
)}
Download
</Button>
</div>
);
}
@@ -98,8 +92,6 @@ export function FileViewer({
isDarkMode,
onSaved,
}: FileViewerProps) {
const { isPaid } = useLicense();
const [content, setContent] = useState('');
const [originalContent, setOriginalContent] = useState('');
const [loading, setLoading] = useState(false);
@@ -109,7 +101,7 @@ export function FileViewer({
const [isOversized, setIsOversized] = useState(false);
const [size, setSize] = useState(0);
const readOnly = !canEdit || !isPaid;
const readOnly = !canEdit;
const editorOptions = useMemo(
() => ({
@@ -220,7 +212,6 @@ export function FileViewer({
label="Binary file"
stackName={stackName}
relPath={selectedPath}
canDownload={isPaid}
/>
);
}
@@ -233,7 +224,6 @@ export function FileViewer({
label="File too large to preview"
stackName={stackName}
relPath={selectedPath}
canDownload={isPaid}
/>
);
}
@@ -2,7 +2,6 @@ import { useState, useEffect, useCallback } from 'react';
import { Trash2, FolderPlus, Download, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/toast-store';
import { useLicense } from '@/context/LicenseContext';
import { downloadStackFile, listStackDirectory } from '@/lib/stackFilesApi';
import { FileTree } from './FileTree';
import { FileViewer } from './FileViewer';
@@ -29,7 +28,6 @@ export function StackFileExplorer({
onNavigateToCompose,
onNavigateToEnv,
}: StackFileExplorerProps) {
const { isPaid } = useLicense();
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [selectedEntry, setSelectedEntry] = useState<FileEntry | null>(null);
const [currentDir, setCurrentDir] = useState('');
@@ -154,7 +152,7 @@ export function StackFileExplorer({
onUploaded={refresh}
/>
</div>
{isPaid && canEdit && (
{canEdit && (
<Button
variant="ghost"
size="icon"
@@ -180,7 +178,6 @@ export function StackFileExplorer({
onNavigateToCompose={onNavigateToCompose}
onNavigateToEnv={onNavigateToEnv}
canEdit={canEdit}
isPaid={isPaid}
onContextMenuRename={handleContextMenuRename}
onContextMenuNewFile={handleContextMenuNewFile}
onContextMenuNewFolder={handleContextMenuNewFolder}
@@ -192,7 +189,7 @@ export function StackFileExplorer({
{/* Right pane: action bar + viewer */}
<div className="flex flex-col flex-1 min-h-0 min-w-0">
{selectedPath !== null && isPaid && (
{selectedPath !== null && (
<div className="flex items-center justify-end gap-1 px-2 py-1 border-b border-glass-border shrink-0">
<Button
variant="ghost"
@@ -299,7 +296,6 @@ export function StackFileExplorer({
stackName={stackName}
relPath={permissionsRelPath}
entryName={permissionsEntryName}
isPaid={isPaid}
canEdit={canEdit}
/>
</div>
@@ -1,13 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi } 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(),
}));
@@ -22,11 +16,7 @@ vi.mock('@/components/ui/toast-store', () => ({
}));
describe('FileUploadDropzone', () => {
beforeEach(() => {
licenseState.isPaid = true;
});
it('renders upload control for paid users with stack edit permission', () => {
it('renders upload control for users with stack edit permission', () => {
render(
<FileUploadDropzone
stackName="app"
@@ -51,19 +41,4 @@ describe('FileUploadDropzone', () => {
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();
});
});
@@ -63,11 +63,6 @@ vi.mock('@/lib/monacoLanguages', () => ({
extensionToLanguage: () => 'plaintext',
}));
const licenseState = { isPaid: true };
vi.mock('@/context/LicenseContext', () => ({
useLicense: () => licenseState,
}));
import { readStackFile } from '@/lib/stackFilesApi';
import { FileViewer } from '../FileViewer';
@@ -93,7 +88,6 @@ const defaultProps = {
beforeEach(() => {
mockReadFile.mockReset();
licenseState.isPaid = true;
});
afterEach(() => vi.clearAllMocks());
@@ -150,7 +144,7 @@ describe('FileViewer', () => {
expect(screen.queryByTestId('monaco-editor')).not.toBeInTheDocument();
});
it('shows Download button when user has a paid tier', async () => {
it('shows the Download button for binary files', async () => {
mockReadFile.mockResolvedValue(binaryResult());
render(<FileViewer {...defaultProps} selectedPath="data.bin" />);
@@ -160,16 +154,6 @@ describe('FileViewer', () => {
expect(downloadBtn).not.toBeDisabled();
});
it('hides Download button for community tier', async () => {
licenseState.isPaid = false;
mockReadFile.mockResolvedValue(binaryResult());
render(<FileViewer {...defaultProps} selectedPath="data.bin" />);
await screen.findByText(/binary file/i);
expect(screen.queryByRole('button', { name: /download/i })).not.toBeInTheDocument();
});
it('re-fetches when selectedPath changes', async () => {
mockReadFile.mockResolvedValue(textResult());