mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 15:46:43 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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} · {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());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user