mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +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();
|
String localIp = GeneralUtils.getLocalNetworkIp();
|
||||||
if (localIp != null) {
|
if (localIp != null) {
|
||||||
String scheme = appConfig.getBackendUrl().startsWith("https") ? "https" : "http";
|
String scheme = appConfig.getBackendUrl().startsWith("https") ? "https" : "http";
|
||||||
return scheme + "://" + localIp + ":" + appConfig.getServerPort();
|
return scheme + "://" + localIp + ":" + resolveEffectiveServerPort(appConfig);
|
||||||
}
|
}
|
||||||
return "";
|
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) {
|
private static boolean isLoopbackHost(String host) {
|
||||||
return "localhost".equalsIgnoreCase(host)
|
return "localhost".equalsIgnoreCase(host)
|
||||||
|| "127.0.0.1".equals(host)
|
|| "127.0.0.1".equals(host)
|
||||||
@@ -161,7 +180,7 @@ public class ConfigController {
|
|||||||
// Note: Frontend expects "baseUrl" field name for compatibility
|
// Note: Frontend expects "baseUrl" field name for compatibility
|
||||||
configData.put("baseUrl", appConfig.getBackendUrl());
|
configData.put("baseUrl", appConfig.getBackendUrl());
|
||||||
configData.put("contextPath", appConfig.getContextPath());
|
configData.put("contextPath", appConfig.getContextPath());
|
||||||
configData.put("serverPort", appConfig.getServerPort());
|
configData.put("serverPort", resolveEffectiveServerPort(appConfig));
|
||||||
|
|
||||||
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
|
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
|
||||||
configData.put("frontendUrl", resolveFrontendUrl(request, appConfig));
|
configData.put("frontendUrl", resolveFrontendUrl(request, appConfig));
|
||||||
|
|||||||
+48
@@ -244,4 +244,52 @@ class ConfigControllerTest {
|
|||||||
assertNotNull(result);
|
assertNotNull(result);
|
||||||
assertFalse(result.contains("localhost"));
|
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."
|
usernameInfo = "Username can only contain letters, numbers and the following special characters @._+- or must be a valid email address."
|
||||||
webOnlyUser = "Web Only User"
|
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]
|
[analytics]
|
||||||
disable = "Disable analytics"
|
disable = "Disable analytics"
|
||||||
enable = "Enable analytics"
|
enable = "Enable analytics"
|
||||||
@@ -2689,58 +2715,15 @@ title = "Change Permissions"
|
|||||||
[changePermissions.tooltip.warning]
|
[changePermissions.tooltip.warning]
|
||||||
text = "To make these permissions unchangeable, use the Add Password tool to set an owner password."
|
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]
|
[chat.header]
|
||||||
settings = "Agent settings"
|
|
||||||
agentMenu = "Stirling agent options"
|
agentMenu = "Stirling agent options"
|
||||||
clearChat = "Clear chat"
|
clearChat = "Clear chat"
|
||||||
|
settings = "Agent settings"
|
||||||
|
|
||||||
[chat.input]
|
[chat.input]
|
||||||
|
attach = "Attach files"
|
||||||
placeholder = "What do you want to do?"
|
placeholder = "What do you want to do?"
|
||||||
send = "Send message"
|
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]
|
[chat.progress]
|
||||||
analyzing = "Analysing your request..."
|
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_read_started = "Reading the document..."
|
||||||
whole_doc_slice_done = "Reading the document... ({{percent}}% complete)"
|
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]
|
[chat.responses]
|
||||||
|
cannot_continue = "Something went wrong and I can't continue."
|
||||||
|
cannot_do = "I'm unable to do that."
|
||||||
done = "Done."
|
done = "Done."
|
||||||
need_clarification = "Could you clarify your request?"
|
need_clarification = "Could you clarify your request?"
|
||||||
cannot_do = "I'm unable to do that."
|
|
||||||
not_found = "I couldn't find the requested information."
|
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}})..."
|
processing = "Processing ({{outcome}})..."
|
||||||
|
unsupported_capability = "Unsupported capability: {{capability}}"
|
||||||
|
|
||||||
[chat.toolsUsed]
|
[chat.toolsUsed]
|
||||||
summary = "Ran {{count}} tools"
|
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)"
|
resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; double-click to auto-fit)"
|
||||||
save = "Save"
|
save = "Save"
|
||||||
saveToServer = "Save to server"
|
saveToServer = "Save to server"
|
||||||
|
saveToServerDisabledHint = "Saving to the server isn't enabled on this server. Ask your admin to enable it."
|
||||||
search = "Search"
|
search = "Search"
|
||||||
searchPlaceholder = "Search this folder & subfolders"
|
searchPlaceholder = "Search this folder & subfolders"
|
||||||
selectAll = "Select all"
|
selectAll = "Select all"
|
||||||
@@ -7959,6 +7960,7 @@ bulkTitle = "Upload checked files"
|
|||||||
description = "This uploads the current file to server storage for your own access."
|
description = "This uploads the current file to server storage for your own access."
|
||||||
errorTitle = "Upload failed"
|
errorTitle = "Upload failed"
|
||||||
failure = "Upload failed. Please check your login and storage settings."
|
failure = "Upload failed. Please check your login and storage settings."
|
||||||
|
featureDisabled = "Saving to the server isn't enabled on this server."
|
||||||
fileCount = "{{count}} files"
|
fileCount = "{{count}} files"
|
||||||
fileLabel = "File"
|
fileLabel = "File"
|
||||||
hint = "Public links and access modes are controlled by your server settings."
|
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]
|
[toolPanel]
|
||||||
allTools = "All tools"
|
allTools = "All tools"
|
||||||
alpha = "Alpha"
|
alpha = "Alpha"
|
||||||
|
backToAllTools = "Back to all tools"
|
||||||
|
backToDefault = "Back"
|
||||||
backToTools = "Back to tools"
|
backToTools = "Back to tools"
|
||||||
collapse = "Collapse panel"
|
collapse = "Collapse panel"
|
||||||
comingSoon = "Coming soon:"
|
comingSoon = "Coming soon:"
|
||||||
expand = "Expand panel"
|
expand = "Expand panel"
|
||||||
|
goBack = "Go back"
|
||||||
placeholder = "Choose a tool to get started"
|
placeholder = "Choose a tool to get started"
|
||||||
premiumFeature = "Premium feature:"
|
premiumFeature = "Premium feature:"
|
||||||
search = "Search tools"
|
search = "Search tools"
|
||||||
toolsHeader = "Tools"
|
toolsHeader = "Tools"
|
||||||
viewAllTools = "View all tools"
|
viewAllTools = "View all tools"
|
||||||
backToDefault = "Back"
|
|
||||||
backToAllTools = "Back to all tools"
|
|
||||||
goBack = "Go back"
|
|
||||||
|
|
||||||
[toolPanel.fullscreen]
|
[toolPanel.fullscreen]
|
||||||
comingSoon = "Coming soon:"
|
comingSoon = "Coming soon:"
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ interface FileDetailsPanelProps {
|
|||||||
onRemove: (fileIds: FileId[]) => void;
|
onRemove: (fileIds: FileId[]) => void;
|
||||||
/** Save to server; only shown when at least one selected file is local-only. */
|
/** Save to server; only shown when at least one selected file is local-only. */
|
||||||
onSaveToServer?: (files: StirlingFileStub[]) => void;
|
onSaveToServer?: (files: StirlingFileStub[]) => void;
|
||||||
|
/** When set, Save to server renders disabled with this tooltip (storage off). */
|
||||||
|
saveToServerDisabledReason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileDetailsPanel({
|
export function FileDetailsPanel({
|
||||||
@@ -51,6 +53,7 @@ export function FileDetailsPanel({
|
|||||||
onMove,
|
onMove,
|
||||||
onRemove,
|
onRemove,
|
||||||
onSaveToServer,
|
onSaveToServer,
|
||||||
|
saveToServerDisabledReason,
|
||||||
}: FileDetailsPanelProps) {
|
}: FileDetailsPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { sharingEnabled } = useSharingEnabled();
|
const { sharingEnabled } = useSharingEnabled();
|
||||||
@@ -319,15 +322,34 @@ export function FileDetailsPanel({
|
|||||||
>
|
>
|
||||||
{t("filesPage.moveTo", "Move to…")}
|
{t("filesPage.moveTo", "Move to…")}
|
||||||
</Button>
|
</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 && (
|
{onSaveToServer && localOnlyFiles.length > 0 && (
|
||||||
<Button
|
<Tooltip
|
||||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
label={saveToServerDisabledReason}
|
||||||
variant="default"
|
disabled={!saveToServerDisabledReason}
|
||||||
onClick={() => onSaveToServer(localOnlyFiles)}
|
withinPortal
|
||||||
|
multiline
|
||||||
|
w={260}
|
||||||
>
|
>
|
||||||
{t("filesPage.saveToServer", "Save to server")}
|
<Button
|
||||||
</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
|
<Button
|
||||||
leftSection={<DeleteIcon fontSize="small" />}
|
leftSection={<DeleteIcon fontSize="small" />}
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ interface FileGridProps {
|
|||||||
onPromptMoveFiles: (fileIds: FileId[]) => void;
|
onPromptMoveFiles: (fileIds: FileId[]) => void;
|
||||||
/** Per-file Save to server; hidden when file already has remoteStorageId. */
|
/** Per-file Save to server; hidden when file already has remoteStorageId. */
|
||||||
onSaveToServer?: (file: StirlingFileStub) => void;
|
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. */
|
/** When supplied the list-view column headers become sortable. */
|
||||||
sortMode?: FilesPageSortMode;
|
sortMode?: FilesPageSortMode;
|
||||||
onChangeSortMode?: (mode: FilesPageSortMode) => void;
|
onChangeSortMode?: (mode: FilesPageSortMode) => void;
|
||||||
@@ -334,6 +336,7 @@ function GridView({
|
|||||||
onRemoveFiles,
|
onRemoveFiles,
|
||||||
onPromptMoveFiles,
|
onPromptMoveFiles,
|
||||||
onSaveToServer,
|
onSaveToServer,
|
||||||
|
saveToServerDisabledReason,
|
||||||
}: FileGridProps) {
|
}: FileGridProps) {
|
||||||
return (
|
return (
|
||||||
<div className="files-page-grid" role="list">
|
<div className="files-page-grid" role="list">
|
||||||
@@ -386,6 +389,7 @@ function GridView({
|
|||||||
onSaveToServer={
|
onSaveToServer={
|
||||||
onSaveToServer ? () => onSaveToServer(entry.file!) : undefined
|
onSaveToServer ? () => onSaveToServer(entry.file!) : undefined
|
||||||
}
|
}
|
||||||
|
saveToServerDisabledReason={saveToServerDisabledReason}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -584,6 +588,8 @@ interface FileCardProps {
|
|||||||
onMove: () => void;
|
onMove: () => void;
|
||||||
/** Kebab Save to server; only fires when file is local-only. */
|
/** Kebab Save to server; only fires when file is local-only. */
|
||||||
onSaveToServer?: () => void;
|
onSaveToServer?: () => void;
|
||||||
|
/** When set, the kebab Save to server is disabled with this tooltip. */
|
||||||
|
saveToServerDisabledReason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileCard({
|
function FileCard({
|
||||||
@@ -599,6 +605,7 @@ function FileCard({
|
|||||||
onRemove,
|
onRemove,
|
||||||
onMove,
|
onMove,
|
||||||
onSaveToServer,
|
onSaveToServer,
|
||||||
|
saveToServerDisabledReason,
|
||||||
}: FileCardProps) {
|
}: FileCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const cardRef = useRef<HTMLDivElement>(null);
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -769,17 +776,33 @@ function FileCard({
|
|||||||
>
|
>
|
||||||
{t("filesPage.moveTo", "Move to…")}
|
{t("filesPage.moveTo", "Move to…")}
|
||||||
</Menu.Item>
|
</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 && (
|
{onSaveToServer && file.remoteStorageId == null && (
|
||||||
<Menu.Item
|
<Tooltip
|
||||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
label={saveToServerDisabledReason}
|
||||||
onClick={(e) => {
|
disabled={!saveToServerDisabledReason}
|
||||||
e.stopPropagation();
|
withinPortal
|
||||||
onSaveToServer();
|
position="left"
|
||||||
}}
|
multiline
|
||||||
|
w={240}
|
||||||
>
|
>
|
||||||
{t("filesPage.saveToServer", "Save to server")}
|
<Menu.Item
|
||||||
</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.Divider />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
@@ -813,6 +836,7 @@ function ListView({
|
|||||||
onRenameFolder,
|
onRenameFolder,
|
||||||
onDeleteFolder,
|
onDeleteFolder,
|
||||||
onSaveToServer,
|
onSaveToServer,
|
||||||
|
saveToServerDisabledReason,
|
||||||
onChangeFolderAppearance,
|
onChangeFolderAppearance,
|
||||||
onRemoveFiles,
|
onRemoveFiles,
|
||||||
onPromptMoveFiles,
|
onPromptMoveFiles,
|
||||||
@@ -946,6 +970,7 @@ function ListView({
|
|||||||
onSaveToServer={
|
onSaveToServer={
|
||||||
onSaveToServer ? () => onSaveToServer(entry.file!) : undefined
|
onSaveToServer ? () => onSaveToServer(entry.file!) : undefined
|
||||||
}
|
}
|
||||||
|
saveToServerDisabledReason={saveToServerDisabledReason}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1148,6 +1173,8 @@ interface FileRowProps {
|
|||||||
onMove: () => void;
|
onMove: () => void;
|
||||||
/** Kebab Save to server; only fires when file is local-only. */
|
/** Kebab Save to server; only fires when file is local-only. */
|
||||||
onSaveToServer?: () => void;
|
onSaveToServer?: () => void;
|
||||||
|
/** When set, the kebab Save to server is disabled with this tooltip. */
|
||||||
|
saveToServerDisabledReason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileRow({
|
function FileRow({
|
||||||
@@ -1163,6 +1190,7 @@ function FileRow({
|
|||||||
onRemove,
|
onRemove,
|
||||||
onMove,
|
onMove,
|
||||||
onSaveToServer,
|
onSaveToServer,
|
||||||
|
saveToServerDisabledReason,
|
||||||
}: FileRowProps) {
|
}: FileRowProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const kebabRef = useRef<HTMLButtonElement>(null);
|
const kebabRef = useRef<HTMLButtonElement>(null);
|
||||||
@@ -1336,17 +1364,33 @@ function FileRow({
|
|||||||
>
|
>
|
||||||
{t("filesPage.moveTo", "Move to…")}
|
{t("filesPage.moveTo", "Move to…")}
|
||||||
</Menu.Item>
|
</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 && (
|
{onSaveToServer && file.remoteStorageId == null && (
|
||||||
<Menu.Item
|
<Tooltip
|
||||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
label={saveToServerDisabledReason}
|
||||||
onClick={(e) => {
|
disabled={!saveToServerDisabledReason}
|
||||||
e.stopPropagation();
|
withinPortal
|
||||||
onSaveToServer();
|
position="left"
|
||||||
}}
|
multiline
|
||||||
|
w={240}
|
||||||
>
|
>
|
||||||
{t("filesPage.saveToServer", "Save to server")}
|
<Menu.Item
|
||||||
</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.Divider />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
|
|||||||
@@ -106,6 +106,17 @@ export default function FileManagerView() {
|
|||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const isMobileUploadAvailable =
|
const isMobileUploadAvailable =
|
||||||
Boolean(appConfig?.enableMobileScanner) && !isMobile;
|
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 [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false);
|
||||||
const { actions: navActions } = useNavigationActions();
|
const { actions: navActions } = useNavigationActions();
|
||||||
const { requestNavigation } = useNavigationGuard();
|
const { requestNavigation } = useNavigationGuard();
|
||||||
@@ -1185,19 +1196,35 @@ export default function FileManagerView() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</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 && (
|
{localOnlySelectedStubs.length > 0 && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={t("filesPage.saveToServer", "Save to server")}
|
label={
|
||||||
|
saveToServerDisabledReason ??
|
||||||
|
t("filesPage.saveToServer", "Save to server")
|
||||||
|
}
|
||||||
withinPortal
|
withinPortal
|
||||||
|
multiline={Boolean(saveToServerDisabledReason)}
|
||||||
|
w={saveToServerDisabledReason ? 240 : undefined}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="default"
|
variant="default"
|
||||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
leftSection={<CloudUploadIcon fontSize="small" />}
|
||||||
|
disabled={Boolean(saveToServerDisabledReason)}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setSaveToServerTarget(localOnlySelectedStubs)
|
setSaveToServerTarget(localOnlySelectedStubs)
|
||||||
}
|
}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
// Keep the tooltip hoverable while disabled.
|
||||||
|
pointerEvents: saveToServerDisabledReason
|
||||||
|
? "auto"
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
}}
|
||||||
aria-label={t(
|
aria-label={t(
|
||||||
"filesPage.saveToServer",
|
"filesPage.saveToServer",
|
||||||
"Save to server",
|
"Save to server",
|
||||||
@@ -1450,6 +1477,7 @@ export default function FileManagerView() {
|
|||||||
onRemoveFiles={handleRemoveFiles}
|
onRemoveFiles={handleRemoveFiles}
|
||||||
onPromptMoveFiles={promptMoveFiles}
|
onPromptMoveFiles={promptMoveFiles}
|
||||||
onSaveToServer={(file) => setSaveToServerTarget([file])}
|
onSaveToServer={(file) => setSaveToServerTarget([file])}
|
||||||
|
saveToServerDisabledReason={saveToServerDisabledReason}
|
||||||
// Center-of-grid CTAs when the empty state shows - same
|
// Center-of-grid CTAs when the empty state shows - same
|
||||||
// handlers the corner header buttons use so behaviour
|
// handlers the corner header buttons use so behaviour
|
||||||
// (disabled tooltips, native file picker, dialog) is
|
// (disabled tooltips, native file picker, dialog) is
|
||||||
@@ -1495,6 +1523,7 @@ export default function FileManagerView() {
|
|||||||
onMove={promptMoveFiles}
|
onMove={promptMoveFiles}
|
||||||
onRemove={handleRemoveFiles}
|
onRemove={handleRemoveFiles}
|
||||||
onSaveToServer={(files) => setSaveToServerTarget(files)}
|
onSaveToServer={(files) => setSaveToServerTarget(files)}
|
||||||
|
saveToServerDisabledReason={saveToServerDisabledReason}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1521,6 +1550,7 @@ export default function FileManagerView() {
|
|||||||
onMove={promptMoveFiles}
|
onMove={promptMoveFiles}
|
||||||
onRemove={handleRemoveFiles}
|
onRemove={handleRemoveFiles}
|
||||||
onSaveToServer={(files) => setSaveToServerTarget(files)}
|
onSaveToServer={(files) => setSaveToServerTarget(files)}
|
||||||
|
saveToServerDisabledReason={saveToServerDisabledReason}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -89,11 +89,21 @@ const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({
|
|||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to upload files to server:", 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(
|
setErrorMessage(
|
||||||
t(
|
status === 403
|
||||||
"storageUpload.failure",
|
? t(
|
||||||
"Upload failed. Please check your login and storage settings.",
|
"storageUpload.featureDisabled",
|
||||||
),
|
"Saving to the server isn't enabled on this server.",
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"storageUpload.failure",
|
||||||
|
"Upload failed. Please check your login and storage settings.",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
|
|||||||
@@ -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.describe("Upload behaviour", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await stubStorageApis(page);
|
await stubStorageApis(page);
|
||||||
|
|||||||
Reference in New Issue
Block a user