mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Disable Save-to-server when storage off, fix QR port 0 (#6473)
This commit is contained in:
+21
-2
@@ -119,11 +119,30 @@ public class ConfigController {
|
||||
String localIp = GeneralUtils.getLocalNetworkIp();
|
||||
if (localIp != null) {
|
||||
String scheme = appConfig.getBackendUrl().startsWith("https") ? "https" : "http";
|
||||
return scheme + "://" + localIp + ":" + appConfig.getServerPort();
|
||||
return scheme + "://" + localIp + ":" + resolveEffectiveServerPort(appConfig);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* The port the embedded server is actually listening on. With {@code server.port=0} (an
|
||||
* ephemeral port, which the desktop bundle uses to dodge port clashes) the configured value
|
||||
* stays {@code "0"} while Spring publishes the real bound port as {@code local.server.port}
|
||||
* once the server is up. Advertised URLs (the mobile-scanner QR, share links) must carry the
|
||||
* real port - a literal {@code :0} is unreachable and browsers reject it as ERR_UNSAFE_PORT.
|
||||
*/
|
||||
// visible for testing
|
||||
String resolveEffectiveServerPort(AppConfig appConfig) {
|
||||
String configured = appConfig.getServerPort();
|
||||
if (configured == null || "0".equals(configured.trim())) {
|
||||
String actual = applicationContext.getEnvironment().getProperty("local.server.port");
|
||||
if (actual != null && !actual.isBlank()) {
|
||||
return actual;
|
||||
}
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
|
||||
private static boolean isLoopbackHost(String host) {
|
||||
return "localhost".equalsIgnoreCase(host)
|
||||
|| "127.0.0.1".equals(host)
|
||||
@@ -161,7 +180,7 @@ public class ConfigController {
|
||||
// Note: Frontend expects "baseUrl" field name for compatibility
|
||||
configData.put("baseUrl", appConfig.getBackendUrl());
|
||||
configData.put("contextPath", appConfig.getContextPath());
|
||||
configData.put("serverPort", appConfig.getServerPort());
|
||||
configData.put("serverPort", resolveEffectiveServerPort(appConfig));
|
||||
|
||||
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
|
||||
configData.put("frontendUrl", resolveFrontendUrl(request, appConfig));
|
||||
|
||||
+48
@@ -244,4 +244,52 @@ class ConfigControllerTest {
|
||||
assertNotNull(result);
|
||||
assertFalse(result.contains("localhost"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveFrontendUrl_usesActualPortWhenServerPortIsEphemeral() {
|
||||
System sys = mock(System.class);
|
||||
when(applicationProperties.getSystem()).thenReturn(sys);
|
||||
when(sys.getFrontendUrl()).thenReturn(null);
|
||||
|
||||
// Loopback host forces the detected-LAN-IP branch, which is where an
|
||||
// ephemeral server.port=0 would otherwise leak through as ":0".
|
||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||
when(req.getServerName()).thenReturn("localhost");
|
||||
|
||||
AppConfig appConfig = mock(AppConfig.class);
|
||||
when(appConfig.getBackendUrl()).thenReturn("http://localhost");
|
||||
when(appConfig.getServerPort()).thenReturn("0");
|
||||
|
||||
org.springframework.core.env.Environment environment =
|
||||
mock(org.springframework.core.env.Environment.class);
|
||||
when(applicationContext.getEnvironment()).thenReturn(environment);
|
||||
when(environment.getProperty("local.server.port")).thenReturn("54321");
|
||||
|
||||
String result = configController.resolveFrontendUrl(req, appConfig);
|
||||
assertNotNull(result);
|
||||
assertTrue(result.endsWith(":54321"));
|
||||
assertFalse(result.contains(":0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveEffectiveServerPort_prefersActualBoundPortWhenConfiguredZero() {
|
||||
AppConfig appConfig = mock(AppConfig.class);
|
||||
when(appConfig.getServerPort()).thenReturn("0");
|
||||
|
||||
org.springframework.core.env.Environment environment =
|
||||
mock(org.springframework.core.env.Environment.class);
|
||||
when(applicationContext.getEnvironment()).thenReturn(environment);
|
||||
when(environment.getProperty("local.server.port")).thenReturn("54321");
|
||||
|
||||
assertEquals("54321", configController.resolveEffectiveServerPort(appConfig));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveEffectiveServerPort_keepsConfiguredNonZeroPort() {
|
||||
AppConfig appConfig = mock(AppConfig.class);
|
||||
when(appConfig.getServerPort()).thenReturn("8080");
|
||||
|
||||
// Non-zero configured port is authoritative; the runtime env is never consulted.
|
||||
assertEquals("8080", configController.resolveEffectiveServerPort(appConfig));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1506,6 +1506,32 @@ user = "User"
|
||||
usernameInfo = "Username can only contain letters, numbers and the following special characters @._+- or must be a valid email address."
|
||||
webOnlyUser = "Web Only User"
|
||||
|
||||
[agents]
|
||||
auto_redaction_description = "Redact PII automatically"
|
||||
auto_redaction_name = "Auto Redaction"
|
||||
back_to_tools = "Back to tools"
|
||||
coming_soon = "Coming soon"
|
||||
compliance_description = "Audit documents for compliance"
|
||||
compliance_name = "Compliance Check"
|
||||
data_extraction_description = "Extract tables & structured data"
|
||||
data_extraction_name = "Data Extraction"
|
||||
doc_summary_description = "Summarise long documents"
|
||||
doc_summary_name = "Summariser"
|
||||
form_filler_description = "Fill PDF forms intelligently"
|
||||
form_filler_name = "Form Filler"
|
||||
fullscreen_title = "Stirling Agents"
|
||||
pdf_to_markdown_description = "Convert PDFs to clean Markdown"
|
||||
pdf_to_markdown_name = "PDF to Markdown"
|
||||
section_title = "Agents"
|
||||
show_less = "Show less"
|
||||
start_chat = "Start chatting"
|
||||
stirling_description = "Your general-purpose PDF assistant"
|
||||
stirling_full_name = "Stirling General Agent"
|
||||
stirling_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents."
|
||||
stirling_name = "Stirling"
|
||||
stirling_tooltip = "Stirling agent"
|
||||
view_all = "View all agents"
|
||||
|
||||
[analytics]
|
||||
disable = "Disable analytics"
|
||||
enable = "Enable analytics"
|
||||
@@ -2689,58 +2715,15 @@ title = "Change Permissions"
|
||||
[changePermissions.tooltip.warning]
|
||||
text = "To make these permissions unchangeable, use the Add Password tool to set an owner password."
|
||||
|
||||
[agents]
|
||||
section_title = "Agents"
|
||||
fullscreen_title = "Stirling Agents"
|
||||
stirling_name = "Stirling"
|
||||
stirling_full_name = "Stirling General Agent"
|
||||
stirling_tooltip = "Stirling agent"
|
||||
stirling_description = "Your general-purpose PDF assistant"
|
||||
stirling_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents."
|
||||
back_to_tools = "Back to tools"
|
||||
coming_soon = "Coming soon"
|
||||
view_all = "View all agents"
|
||||
show_less = "Show less"
|
||||
start_chat = "Start chatting"
|
||||
data_extraction_name = "Data Extraction"
|
||||
data_extraction_description = "Extract tables & structured data"
|
||||
doc_summary_name = "Summariser"
|
||||
doc_summary_description = "Summarise long documents"
|
||||
auto_redaction_name = "Auto Redaction"
|
||||
auto_redaction_description = "Redact PII automatically"
|
||||
compliance_name = "Compliance Check"
|
||||
compliance_description = "Audit documents for compliance"
|
||||
form_filler_name = "Form Filler"
|
||||
form_filler_description = "Fill PDF forms intelligently"
|
||||
pdf_to_markdown_name = "PDF to Markdown"
|
||||
pdf_to_markdown_description = "Convert PDFs to clean Markdown"
|
||||
|
||||
[chat.header]
|
||||
settings = "Agent settings"
|
||||
agentMenu = "Stirling agent options"
|
||||
clearChat = "Clear chat"
|
||||
settings = "Agent settings"
|
||||
|
||||
[chat.input]
|
||||
attach = "Attach files"
|
||||
placeholder = "What do you want to do?"
|
||||
send = "Send message"
|
||||
attach = "Attach files"
|
||||
|
||||
[chat.quickActions]
|
||||
heading = "Get started"
|
||||
openFromComputer = "Open from computer"
|
||||
browseYourFiles = "Browse your files"
|
||||
rotateOne = "Rotate this document"
|
||||
rotateMany = "Rotate these documents"
|
||||
compressOne = "Compress this document"
|
||||
compressMany = "Compress these documents"
|
||||
mergeMany = "Merge these {{count}} documents into 1"
|
||||
splitOne = "Split this document"
|
||||
convertOne = "Convert this document to PDF"
|
||||
convertMany = "Convert these documents to PDF"
|
||||
fileSummary_one = "1 file in workbench ({{types}})"
|
||||
fileSummary_other = "{{count}} files in workbench ({{types}})"
|
||||
moreFiles = "+{{count}} more"
|
||||
removeFile = "Remove {{name}}"
|
||||
|
||||
[chat.progress]
|
||||
analyzing = "Analysing your request..."
|
||||
@@ -2757,14 +2740,31 @@ whole_doc_read_done = "Finished reading the document..."
|
||||
whole_doc_read_started = "Reading the document..."
|
||||
whole_doc_slice_done = "Reading the document... ({{percent}}% complete)"
|
||||
|
||||
[chat.quickActions]
|
||||
browseYourFiles = "Browse your files"
|
||||
compressMany = "Compress these documents"
|
||||
compressOne = "Compress this document"
|
||||
convertMany = "Convert these documents to PDF"
|
||||
convertOne = "Convert this document to PDF"
|
||||
fileSummary_one = "1 file in workbench ({{types}})"
|
||||
fileSummary_other = "{{count}} files in workbench ({{types}})"
|
||||
heading = "Get started"
|
||||
mergeMany = "Merge these {{count}} documents into 1"
|
||||
moreFiles = "+{{count}} more"
|
||||
openFromComputer = "Open from computer"
|
||||
removeFile = "Remove {{name}}"
|
||||
rotateMany = "Rotate these documents"
|
||||
rotateOne = "Rotate this document"
|
||||
splitOne = "Split this document"
|
||||
|
||||
[chat.responses]
|
||||
cannot_continue = "Something went wrong and I can't continue."
|
||||
cannot_do = "I'm unable to do that."
|
||||
done = "Done."
|
||||
need_clarification = "Could you clarify your request?"
|
||||
cannot_do = "I'm unable to do that."
|
||||
not_found = "I couldn't find the requested information."
|
||||
unsupported_capability = "Unsupported capability: {{capability}}"
|
||||
cannot_continue = "Something went wrong and I can't continue."
|
||||
processing = "Processing ({{outcome}})..."
|
||||
unsupported_capability = "Unsupported capability: {{capability}}"
|
||||
|
||||
[chat.toolsUsed]
|
||||
summary = "Ran {{count}} tools"
|
||||
@@ -3915,6 +3915,7 @@ renameFolder = "Rename folder"
|
||||
resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; double-click to auto-fit)"
|
||||
save = "Save"
|
||||
saveToServer = "Save to server"
|
||||
saveToServerDisabledHint = "Saving to the server isn't enabled on this server. Ask your admin to enable it."
|
||||
search = "Search"
|
||||
searchPlaceholder = "Search this folder & subfolders"
|
||||
selectAll = "Select all"
|
||||
@@ -7959,6 +7960,7 @@ bulkTitle = "Upload checked files"
|
||||
description = "This uploads the current file to server storage for your own access."
|
||||
errorTitle = "Upload failed"
|
||||
failure = "Upload failed. Please check your login and storage settings."
|
||||
featureDisabled = "Saving to the server isn't enabled on this server."
|
||||
fileCount = "{{count}} files"
|
||||
fileLabel = "File"
|
||||
hint = "Public links and access modes are controlled by your server settings."
|
||||
@@ -8132,18 +8134,18 @@ viewerMode = "Switch to the file editor to add multiple files."
|
||||
[toolPanel]
|
||||
allTools = "All tools"
|
||||
alpha = "Alpha"
|
||||
backToAllTools = "Back to all tools"
|
||||
backToDefault = "Back"
|
||||
backToTools = "Back to tools"
|
||||
collapse = "Collapse panel"
|
||||
comingSoon = "Coming soon:"
|
||||
expand = "Expand panel"
|
||||
goBack = "Go back"
|
||||
placeholder = "Choose a tool to get started"
|
||||
premiumFeature = "Premium feature:"
|
||||
search = "Search tools"
|
||||
toolsHeader = "Tools"
|
||||
viewAllTools = "View all tools"
|
||||
backToDefault = "Back"
|
||||
backToAllTools = "Back to all tools"
|
||||
goBack = "Go back"
|
||||
|
||||
[toolPanel.fullscreen]
|
||||
comingSoon = "Coming soon:"
|
||||
|
||||
@@ -39,6 +39,8 @@ interface FileDetailsPanelProps {
|
||||
onRemove: (fileIds: FileId[]) => void;
|
||||
/** Save to server; only shown when at least one selected file is local-only. */
|
||||
onSaveToServer?: (files: StirlingFileStub[]) => void;
|
||||
/** When set, Save to server renders disabled with this tooltip (storage off). */
|
||||
saveToServerDisabledReason?: string | null;
|
||||
}
|
||||
|
||||
export function FileDetailsPanel({
|
||||
@@ -51,6 +53,7 @@ export function FileDetailsPanel({
|
||||
onMove,
|
||||
onRemove,
|
||||
onSaveToServer,
|
||||
saveToServerDisabledReason,
|
||||
}: FileDetailsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { sharingEnabled } = useSharingEnabled();
|
||||
@@ -319,15 +322,34 @@ export function FileDetailsPanel({
|
||||
>
|
||||
{t("filesPage.moveTo", "Move to…")}
|
||||
</Button>
|
||||
{/* Save to server; shown when any selected file is local-only. */}
|
||||
{/* Save to server; shown when any selected file is local-only. When
|
||||
storage is off it stays visible but disabled with a tooltip (same
|
||||
treatment as Manage sharing above). */}
|
||||
{onSaveToServer && localOnlyFiles.length > 0 && (
|
||||
<Tooltip
|
||||
label={saveToServerDisabledReason}
|
||||
disabled={!saveToServerDisabledReason}
|
||||
withinPortal
|
||||
multiline
|
||||
w={260}
|
||||
>
|
||||
<Button
|
||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
||||
variant="default"
|
||||
disabled={Boolean(saveToServerDisabledReason)}
|
||||
onClick={() => onSaveToServer(localOnlyFiles)}
|
||||
styles={{
|
||||
root: {
|
||||
// Keep tooltip hoverable while button is disabled.
|
||||
pointerEvents: saveToServerDisabledReason
|
||||
? "auto"
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t("filesPage.saveToServer", "Save to server")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
leftSection={<DeleteIcon fontSize="small" />}
|
||||
|
||||
@@ -78,6 +78,8 @@ interface FileGridProps {
|
||||
onPromptMoveFiles: (fileIds: FileId[]) => void;
|
||||
/** Per-file Save to server; hidden when file already has remoteStorageId. */
|
||||
onSaveToServer?: (file: StirlingFileStub) => void;
|
||||
/** When set, the Save to server item renders disabled with this tooltip. */
|
||||
saveToServerDisabledReason?: string | null;
|
||||
/** When supplied the list-view column headers become sortable. */
|
||||
sortMode?: FilesPageSortMode;
|
||||
onChangeSortMode?: (mode: FilesPageSortMode) => void;
|
||||
@@ -334,6 +336,7 @@ function GridView({
|
||||
onRemoveFiles,
|
||||
onPromptMoveFiles,
|
||||
onSaveToServer,
|
||||
saveToServerDisabledReason,
|
||||
}: FileGridProps) {
|
||||
return (
|
||||
<div className="files-page-grid" role="list">
|
||||
@@ -386,6 +389,7 @@ function GridView({
|
||||
onSaveToServer={
|
||||
onSaveToServer ? () => onSaveToServer(entry.file!) : undefined
|
||||
}
|
||||
saveToServerDisabledReason={saveToServerDisabledReason}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -584,6 +588,8 @@ interface FileCardProps {
|
||||
onMove: () => void;
|
||||
/** Kebab Save to server; only fires when file is local-only. */
|
||||
onSaveToServer?: () => void;
|
||||
/** When set, the kebab Save to server is disabled with this tooltip. */
|
||||
saveToServerDisabledReason?: string | null;
|
||||
}
|
||||
|
||||
function FileCard({
|
||||
@@ -599,6 +605,7 @@ function FileCard({
|
||||
onRemove,
|
||||
onMove,
|
||||
onSaveToServer,
|
||||
saveToServerDisabledReason,
|
||||
}: FileCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
@@ -769,17 +776,33 @@ function FileCard({
|
||||
>
|
||||
{t("filesPage.moveTo", "Move to…")}
|
||||
</Menu.Item>
|
||||
{/* Per-file Save to server; hidden when already on server. */}
|
||||
{/* Per-file Save to server; shown for local-only files. When
|
||||
storage is off it stays visible but disabled with a tooltip. */}
|
||||
{onSaveToServer && file.remoteStorageId == null && (
|
||||
<Tooltip
|
||||
label={saveToServerDisabledReason}
|
||||
disabled={!saveToServerDisabledReason}
|
||||
withinPortal
|
||||
position="left"
|
||||
multiline
|
||||
w={240}
|
||||
>
|
||||
<Menu.Item
|
||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
||||
disabled={Boolean(saveToServerDisabledReason)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSaveToServer();
|
||||
}}
|
||||
style={
|
||||
saveToServerDisabledReason
|
||||
? { pointerEvents: "auto" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("filesPage.saveToServer", "Save to server")}
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
@@ -813,6 +836,7 @@ function ListView({
|
||||
onRenameFolder,
|
||||
onDeleteFolder,
|
||||
onSaveToServer,
|
||||
saveToServerDisabledReason,
|
||||
onChangeFolderAppearance,
|
||||
onRemoveFiles,
|
||||
onPromptMoveFiles,
|
||||
@@ -946,6 +970,7 @@ function ListView({
|
||||
onSaveToServer={
|
||||
onSaveToServer ? () => onSaveToServer(entry.file!) : undefined
|
||||
}
|
||||
saveToServerDisabledReason={saveToServerDisabledReason}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1148,6 +1173,8 @@ interface FileRowProps {
|
||||
onMove: () => void;
|
||||
/** Kebab Save to server; only fires when file is local-only. */
|
||||
onSaveToServer?: () => void;
|
||||
/** When set, the kebab Save to server is disabled with this tooltip. */
|
||||
saveToServerDisabledReason?: string | null;
|
||||
}
|
||||
|
||||
function FileRow({
|
||||
@@ -1163,6 +1190,7 @@ function FileRow({
|
||||
onRemove,
|
||||
onMove,
|
||||
onSaveToServer,
|
||||
saveToServerDisabledReason,
|
||||
}: FileRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const kebabRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -1336,17 +1364,33 @@ function FileRow({
|
||||
>
|
||||
{t("filesPage.moveTo", "Move to…")}
|
||||
</Menu.Item>
|
||||
{/* Per-file Save to server; hidden when already on server. */}
|
||||
{/* Per-file Save to server; shown for local-only files. When
|
||||
storage is off it stays visible but disabled with a tooltip. */}
|
||||
{onSaveToServer && file.remoteStorageId == null && (
|
||||
<Tooltip
|
||||
label={saveToServerDisabledReason}
|
||||
disabled={!saveToServerDisabledReason}
|
||||
withinPortal
|
||||
position="left"
|
||||
multiline
|
||||
w={240}
|
||||
>
|
||||
<Menu.Item
|
||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
||||
disabled={Boolean(saveToServerDisabledReason)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSaveToServer();
|
||||
}}
|
||||
style={
|
||||
saveToServerDisabledReason
|
||||
? { pointerEvents: "auto" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("filesPage.saveToServer", "Save to server")}
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
|
||||
@@ -106,6 +106,17 @@ export default function FileManagerView() {
|
||||
const isMobile = useIsMobile();
|
||||
const isMobileUploadAvailable =
|
||||
Boolean(appConfig?.enableMobileScanner) && !isMobile;
|
||||
// Server storage gate; mirrors ConfigController's storageEnabled
|
||||
// (enableLogin && storage.isEnabled). When off, Save-to-server stays
|
||||
// visible but disabled with an explanatory tooltip (discoverability beats
|
||||
// hiding - mirrors the New folder / Manage sharing gates in this view).
|
||||
const uploadEnabled = appConfig?.storageEnabled === true;
|
||||
const saveToServerDisabledReason: string | null = uploadEnabled
|
||||
? null
|
||||
: t(
|
||||
"filesPage.saveToServerDisabledHint",
|
||||
"Saving to the server isn't enabled on this server. Ask your admin to enable it.",
|
||||
);
|
||||
const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false);
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const { requestNavigation } = useNavigationGuard();
|
||||
@@ -1185,19 +1196,35 @@ export default function FileManagerView() {
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Save to server; hidden when no local-only file selected. */}
|
||||
{/* Save to server; shown whenever local-only files are
|
||||
selected. When storage is off it stays visible but
|
||||
disabled, tooltip pointing at the admin. */}
|
||||
{localOnlySelectedStubs.length > 0 && (
|
||||
<Tooltip
|
||||
label={t("filesPage.saveToServer", "Save to server")}
|
||||
label={
|
||||
saveToServerDisabledReason ??
|
||||
t("filesPage.saveToServer", "Save to server")
|
||||
}
|
||||
withinPortal
|
||||
multiline={Boolean(saveToServerDisabledReason)}
|
||||
w={saveToServerDisabledReason ? 240 : undefined}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
||||
disabled={Boolean(saveToServerDisabledReason)}
|
||||
onClick={() =>
|
||||
setSaveToServerTarget(localOnlySelectedStubs)
|
||||
}
|
||||
styles={{
|
||||
root: {
|
||||
// Keep the tooltip hoverable while disabled.
|
||||
pointerEvents: saveToServerDisabledReason
|
||||
? "auto"
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
aria-label={t(
|
||||
"filesPage.saveToServer",
|
||||
"Save to server",
|
||||
@@ -1450,6 +1477,7 @@ export default function FileManagerView() {
|
||||
onRemoveFiles={handleRemoveFiles}
|
||||
onPromptMoveFiles={promptMoveFiles}
|
||||
onSaveToServer={(file) => setSaveToServerTarget([file])}
|
||||
saveToServerDisabledReason={saveToServerDisabledReason}
|
||||
// Center-of-grid CTAs when the empty state shows - same
|
||||
// handlers the corner header buttons use so behaviour
|
||||
// (disabled tooltips, native file picker, dialog) is
|
||||
@@ -1495,6 +1523,7 @@ export default function FileManagerView() {
|
||||
onMove={promptMoveFiles}
|
||||
onRemove={handleRemoveFiles}
|
||||
onSaveToServer={(files) => setSaveToServerTarget(files)}
|
||||
saveToServerDisabledReason={saveToServerDisabledReason}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1521,6 +1550,7 @@ export default function FileManagerView() {
|
||||
onMove={promptMoveFiles}
|
||||
onRemove={handleRemoveFiles}
|
||||
onSaveToServer={(files) => setSaveToServerTarget(files)}
|
||||
saveToServerDisabledReason={saveToServerDisabledReason}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
@@ -89,8 +89,18 @@ const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to upload files to server:", error);
|
||||
// A 403 means the server has storage turned off (or login disabled,
|
||||
// which gates storage). Say so plainly instead of the generic
|
||||
// "check your settings" message, which reads as a user mistake.
|
||||
const status = (error as { response?: { status?: number } })?.response
|
||||
?.status;
|
||||
setErrorMessage(
|
||||
t(
|
||||
status === 403
|
||||
? t(
|
||||
"storageUpload.featureDisabled",
|
||||
"Saving to the server isn't enabled on this server.",
|
||||
)
|
||||
: t(
|
||||
"storageUpload.failure",
|
||||
"Upload failed. Please check your login and storage settings.",
|
||||
),
|
||||
|
||||
@@ -284,6 +284,52 @@ test.describe("Files page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Save to server gating (storage disabled)", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// storageEnabled:false -> Save-to-server stays visible for local-only
|
||||
// files but is disabled (with an explanatory tooltip), not hidden, so
|
||||
// users discover the feature and know to ask their admin.
|
||||
await stubStorageApis(page, { storageEnabled: false });
|
||||
await seedFiles(page, [
|
||||
{ id: "local-a", name: "local-a.pdf", remoteStorageId: null },
|
||||
]);
|
||||
});
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("bulk Save to server is disabled (not hidden) when storage off", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
await page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "local-a.pdf" })
|
||||
.click();
|
||||
const saveButtons = page.getByRole("button", {
|
||||
name: /^Save to server$/i,
|
||||
});
|
||||
// Present (toolbar + details panel) and every instance disabled.
|
||||
const count = await saveButtons.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
await expect(saveButtons.nth(i)).toBeVisible();
|
||||
await expect(saveButtons.nth(i)).toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
test("per-file kebab Save to server is disabled (not hidden) when storage off", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
const localCard = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "local-a.pdf" });
|
||||
await localCard.getByRole("button", { name: /File actions/i }).click();
|
||||
const item = page.getByRole("menuitem", { name: /^Save to server$/i });
|
||||
await expect(item).toBeVisible();
|
||||
await expect(item).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Upload behaviour", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
|
||||
Reference in New Issue
Block a user