MCP OAuth discovery fix + Supabase consent page (#6608)

This commit is contained in:
Anthony Stirling
2026-06-11 18:30:49 +01:00
committed by GitHub
parent 9e5fe2f4ca
commit 1d598d5caa
18 changed files with 1146 additions and 270 deletions
@@ -275,7 +275,6 @@ saved = "Saved"
text = "Text"
[addPageNumbers]
tags = "number,pagination,count,add page numbers,page numbering,page numbers,footer,header,number pages,sequential,pagination tool"
customize = "Customize Appearance"
customNumberDesc = "e.g., \"Page {n}\" or leave blank for just numbers"
fontName = "Font Name"
@@ -286,6 +285,7 @@ positionAndPages = "Position & Pages"
preview = "Position Selection"
previewDisclaimer = "Preview is approximate. Final output may vary due to PDF font metrics."
submit = "Add Page Numbers"
tags = "number,pagination,count,add page numbers,page numbering,page numbers,footer,header,number pages,sequential,pagination tool"
title = "Add Page Numbers"
zeroPad = "Zeropad Width (Bates Stamping)"
zeroPadTooltip = "Zeropad (Bates Stamp) page numbers to this width (e.g., 3 ⇒ 001). Set 0 to disable."
@@ -303,9 +303,9 @@ title = "Page Number Results"
6 = "Custom Text Format"
[addPassword]
tags = "encrypt,password,lock,secure,protect,security,encryption,safeguard,confidential,private,restrict access"
desc = "Encrypt your PDF document with a password."
submit = "Encrypt"
tags = "encrypt,password,lock,secure,protect,security,encryption,safeguard,confidential,private,restrict access"
title = "Add Password"
[addPassword.encryption.keyLength]
@@ -351,6 +351,9 @@ title = "Password Types"
text = "These permissions control what users can do with the PDF. Most effective when combined with an owner password."
title = "Change Permissions"
[addStamp]
tags = "stamp,mark,seal,approved,rejected,confidential,stamp tool,rubber stamp,date stamp,approval stamp,received,void,copy,original"
[AddStampRequest]
alphabet = "Alphabet"
clickToExpand = "Click to expand"
@@ -421,6 +424,9 @@ header = "Add text to PDFs"
tags = "text,annotation,label"
title = "Add Text"
[addText.canvas]
colorPickerTitle = "Choose stroke colour"
[addText.error]
failed = "An error occurred while adding text to the PDF."
@@ -440,25 +446,6 @@ resume = "Resume placement"
[addText.results]
title = "Add Text Results"
[addText.step]
createDesc = "Enter the text you want to add"
place = "Place text"
placeDesc = "Click on the PDF to add your text"
[addText.steps]
configure = "Configure Text"
[addText.text]
colorLabel = "Text colour"
fontLabel = "Font"
fontSizeLabel = "Font size"
fontSizePlaceholder = "Type or select font size (8-200)"
name = "Text content"
placeholder = "Enter the text you want to add"
[addText.canvas]
colorPickerTitle = "Choose stroke colour"
[addText.saved]
defaultCanvasLabel = "Drawing signature"
defaultImageLabel = "Uploaded signature"
@@ -481,6 +468,22 @@ canvas = "Drawing"
image = "Upload"
text = "Text"
[addText.step]
createDesc = "Enter the text you want to add"
place = "Place text"
placeDesc = "Click on the PDF to add your text"
[addText.steps]
configure = "Configure Text"
[addText.text]
colorLabel = "Text colour"
fontLabel = "Font"
fontSizeLabel = "Font size"
fontSizePlaceholder = "Type or select font size (8-200)"
name = "Text content"
placeholder = "Enter the text you want to add"
[addText.type]
canvas = "Canvas"
image = "Image"
@@ -491,7 +494,6 @@ text = "Text"
tags = "color-correction,tune,modify,enhance,colour-correction"
[adjustContrast]
tags = "contrast,brightness,saturation,adjust colors,color correction,enhance,lighten,darken,improve quality,color balance,hue,vibrance"
adjustColors = "Adjust Colors"
basic = "Basic Adjustments"
blue = "Blue"
@@ -502,6 +504,7 @@ green = "Green"
noPreview = "Select a PDF to preview"
red = "Red"
saturation = "Saturation:"
tags = "contrast,brightness,saturation,adjust colors,color correction,enhance,lighten,darken,improve quality,color balance,hue,vibrance"
title = "Adjust Colors/Contrast"
[adjustContrast.error]
@@ -1131,6 +1134,10 @@ apikeyNote = "Clients send a Stirling API key in the X-API-KEY header (or Author
description = "Expose Stirling's PDF tools and AI agents to MCP clients over an OAuth-protected endpoint."
title = "MCP Server"
[admin.settings.mcp.acceptedAudiences]
description = "Extra token audience values accepted besides the Resource ID (comma or space separated). Leave blank for strict RFC 8707. Needed for IdPs that cannot mint resource audiences - e.g. Supabase's OAuth server always issues aud=authenticated."
label = "Additional accepted audiences (optional)"
[admin.settings.mcp.allowedOps]
description = "If set, ONLY these operation ids are exposed (an allow-list; comma or space separated). Leave blank to expose all enabled tools except any blocked below."
label = "Allowed tools"
@@ -1603,6 +1610,9 @@ paragraph1 = "Stirling PDF has opt-in analytics to help us improve the product.
paragraph2 = "Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better."
title = "Do you want to help make Stirling PDF better?"
[annotate]
tags = "annotate,highlight,draw,markup,comment,notes,review,redline,feedback,markup tools,sticky notes,shapes,arrows,text box,freehand"
[annotation]
annotationStyle = "Annotation style"
backgroundColor = "Background colour"
@@ -1832,7 +1842,6 @@ bullet3 = "Keeps the original name if no suitable title is found"
title = "Smart Renaming"
[automate]
tags = "workflow,sequence,automation,automate,batch,batch processing,pipeline,chain,multi-step,recurring,scheduled,automatic,process multiple,bulk operations"
copyToSaved = "Copy to Saved"
desc = "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
export = "Export"
@@ -1842,6 +1851,7 @@ importPartialSuccess = "Imported with {{count}} unmapped operation(s): {{ops}}"
importSuccess = "Imported automation: {{name}}"
invalidStep = "Invalid step"
reviewTitle = "Automation Results"
tags = "workflow,sequence,automation,automate,batch,batch processing,pipeline,chain,multi-step,recurring,scheduled,automatic,process multiple,bulk operations"
title = "Automate"
[automate.config]
@@ -1939,8 +1949,8 @@ secureWorkflow = "Security Workflow"
secureWorkflowDesc = "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorised access. Password is set to 'password' by default."
[autoRename]
tags = "auto-detect,header-based,organize,relabel,auto rename,automatic rename,smart rename,rename by content,filename,file naming,detect title"
description = "This tool will automatically rename PDF files based on their content. It analyzes the document to find the most suitable title from the text."
tags = "auto-detect,header-based,organize,relabel,auto rename,automatic rename,smart rename,rename by content,filename,file naming,detect title"
[autoSizeSplitPDF]
tags = "pdf,split,document,organization"
@@ -2520,9 +2530,9 @@ ssoManaged = "Your account is managed by your identity provider."
title = "Change Credentials"
[changeMetadata]
tags = "edit,modify,update,metadata,properties,document properties,author,title,subject,keywords,creator,producer,info,document info,file properties"
filenamePrefix = "metadata"
submit = "Change"
tags = "edit,modify,update,metadata,properties,document properties,author,title,subject,keywords,creator,producer,info,document info,file properties"
[changeMetadata.advanced]
title = "Advanced Options"
@@ -2628,9 +2638,9 @@ true = "True"
unknown = "Unknown"
[changePermissions]
tags = "permissions,restrictions,rights,access control,allow,deny,printing,copying,editing,modify permissions,security settings,user rights"
desc = "Change document restrictions and permissions."
submit = "Change Permissions"
tags = "permissions,restrictions,rights,access control,allow,deny,printing,copying,editing,modify permissions,security settings,user rights"
title = "Change Permissions"
[changePermissions.error]
@@ -2678,6 +2688,10 @@ resize = "Resize chat panel"
[chat.actions]
copy = "Copy message"
[chat.fab]
close = "Close chat"
open = "Open Stirling AI assistant"
[chat.header]
agentMenu = "Stirling agent options"
clearChat = "Clear chat"
@@ -2695,18 +2709,18 @@ executing_tool_single = "Running {{tool}}..."
executing_tool_step = "Running {{tool}} (step {{step}} of {{total}})..."
extracting_content = "Extracting content from your documents..."
processing = "Processing extracted content..."
ranForMinutes = "Ran for {{count}} minutes"
ranForMinutes_one = "Ran for 1 minute"
ranForMinutes_other = "Ran for {{count}} minutes"
ranForMinutesSeconds = "Ran for {{minutes}} min, {{seconds}} sec"
ranForSeconds = "Ran for {{count}} seconds"
ranForSeconds_one = "Ran for 1 second"
ranForSeconds_other = "Ran for {{count}} seconds"
thinking = "Thinking..."
whole_doc_compression_round = "Consolidating notes..."
whole_doc_read_done = "Finished reading the document..."
whole_doc_read_started = "Reading the document..."
whole_doc_slice_done = "Reading the document... ({{percent}}% complete)"
ranForSeconds = "Ran for {{count}} seconds"
ranForSeconds_one = "Ran for 1 second"
ranForSeconds_other = "Ran for {{count}} seconds"
ranForMinutes = "Ran for {{count}} minutes"
ranForMinutes_one = "Ran for 1 minute"
ranForMinutes_other = "Ran for {{count}} minutes"
ranForMinutesSeconds = "Ran for {{minutes}} min, {{seconds}} sec"
[chat.quickActions]
browseYourFiles = "Browse your files"
@@ -2736,10 +2750,6 @@ unsupported_capability = "Unsupported capability: {{capability}}"
summary = "Ran {{count}} tools"
unknownTool = "Unknown tool"
[chat.fab]
close = "Close chat"
open = "Open Stirling AI assistant"
[cloudBadge]
tooltip = "This operation will use your cloud credits"
@@ -2895,9 +2905,9 @@ unlinkedTitle = "Independent scroll & pan enabled"
message = "These documents appear highly dissimilar. Comparison was stopped to save time."
[compress]
tags = "shrink,reduce,optimize,compress,smaller,downsize,file size,reduce size,minimize,make smaller,decrease size,optimize size"
desc = "Compress PDFs to reduce their file size."
submit = "Compress"
tags = "shrink,reduce,optimize,compress,smaller,downsize,file size,reduce size,minimize,make smaller,decrease size,optimize size"
title = "Compress"
[compress.compressionLevel]
@@ -3102,7 +3112,6 @@ selfhostedOffline = "Self-hosted server unreachable"
selfhostedOnline = "Connected to self-hosted server"
[convert]
tags = "transform,change,convert,PDF to Word,PDF to Excel,PDF to image,Word to PDF,Excel to PDF,PowerPoint to PDF,HTML to PDF,export,import,file conversion,format change,save as"
autoRotate = "Auto Rotate"
autoRotateDescription = "Automatically rotate images to better fit the PDF page"
blackwhite = "Black & White"
@@ -3162,6 +3171,7 @@ strictModeDesc = "Error if conversion is not perfect (uses VeraPDF verification)
svgPdfOptions = "SVG to PDF Options"
svgToPdf = "SVG → PDF"
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
tags = "transform,change,convert,PDF to Word,PDF to Excel,PDF to image,Word to PDF,Excel to PDF,PowerPoint to PDF,HTML to PDF,export,import,file conversion,format change,save as"
targetFormatPlaceholder = "Target format"
title = "Convert"
webOptions = "Web to PDF Options"
@@ -3381,6 +3391,18 @@ docsLink = "View installation documentation"
message = "Stirling-PDF does not have permission to update itself on this machine."
title = "Administrator permissions required"
[devAirgapped]
tags = "air-gapped,offline,isolated,no internet,disconnected,secure,network isolation,standalone"
[devApi]
tags = "API,development,documentation,developer,REST,integration,endpoints,programmatic,automation,scripting"
[devFolderScanning]
tags = "automation,folder,scanning,watch folder,hot folder,automatic processing,batch,monitor folder,auto process,folder monitoring"
[devSsoGuide]
tags = "SSO,single sign-on,authentication,SAML,OAuth,OIDC,login,enterprise,identity provider,IdP"
[dropdownList]
searchPlaceholder = "Search..."
@@ -3389,8 +3411,8 @@ edit = "Edit"
editSecretValue = "Edit secret value"
[editTableOfContents]
tags = "bookmarks,contents,edit,table of contents,TOC,outline,navigation,chapters,sections,add bookmarks,edit bookmarks,PDF outline"
submit = "Apply table of contents"
tags = "bookmarks,contents,edit,table of contents,TOC,outline,navigation,chapters,sections,add bookmarks,edit bookmarks,PDF outline"
[editTableOfContents.actions]
clipboardUnavailable = "Clipboard access is not available in this browser."
@@ -3524,8 +3546,8 @@ title = "Settings"
tags = "extract"
[extractPages]
tags = "pull,select,copy,extract,extract pages,get pages,pull out,save pages,export pages,copy pages,select pages,specific pages"
submit = "Extract Pages"
tags = "pull,select,copy,extract,extract pages,get pages,pull out,save pages,export pages,copy pages,select pages,specific pages"
title = "Extract Pages"
[extractPages.error]
@@ -3923,8 +3945,8 @@ welcomeMessage = "For security reasons, you must change your password on your fi
welcomeTitle = "Welcome!"
[flatten]
tags = "simplify,remove,interactive,flatten,flatten form,remove form fields,make static,finalize form,lock form,disable editing,convert to image,non-editable"
submit = "Flatten"
tags = "simplify,remove,interactive,flatten,flatten form,remove form fields,make static,finalize form,lock form,disable editing,convert to image,non-editable"
title = "Flatten"
[flatten.error]
@@ -4569,6 +4591,7 @@ signIn = "Sign In"
accountCreatedSuccess = "Account created successfully! You can now sign in."
backToSignIn = "Back to sign in"
changePasswordWarning = "Please change your password after logging in for the first time"
createAccount = "Create an account"
credentialsUpdated = "Your credentials have been updated. Please sign in again."
defaultCredentials = "Default Login Credentials"
email = "Email"
@@ -4598,7 +4621,6 @@ sending = "Sending…"
sendMagicLink = "Send Magic Link"
sendResetLink = "Send reset link"
sessionExpired = "Your session has expired. Please sign in again."
createAccount = "Create an account"
signin = "Sign in"
signingIn = "Signing in..."
signInWith = "Sign in with"
@@ -4736,6 +4758,34 @@ title = "Authentication Failed"
message = "You can close this window and return to Stirling PDF."
title = "Authentication Successful"
[oauthConsent]
approve = "Allow access"
approving = "Allowing..."
decisionFailed = "Could not submit your decision. Please try again."
deny = "Deny"
denying = "Denying..."
loadFailed = "This authorization request is invalid or has expired. Close this tab and try connecting again from the app."
loading = "Loading authorization request..."
missingId = "Missing authorization request. Start the connection from your app and try again."
redirecting = "Returning you to the app..."
requesting = "{{app}} wants to access your Stirling PDF account"
scopesIntro = "This will allow {{app}} to:"
signedInAs = "Signed in as {{name}}"
signInButton = "Sign in to continue"
signInPrompt = "Sign in to your Stirling PDF account to continue connecting the app."
title = "Authorize access"
unknownApp = "A third-party application"
[oauthConsent.access]
actAsYou = "Act as you - everything {{app}} does runs under your account and counts towards your usage"
tools = "Use your Stirling PDF tools on your behalf - convert, edit, sign, secure and process your documents"
[oauthConsent.scope]
email = "See your email address"
openid = "Confirm your identity"
phone = "See your phone number"
profile = "See your basic profile information"
[ocr]
desc = "Cleanup scans and detects text from images within a PDF and re-adds it as text."
tags = "recognition,text,image,scan,read,identify,detection,editable"
@@ -5477,7 +5527,6 @@ REVERSE_ORDER = "Flip the document so the last page becomes first and so on."
SIDE_STITCH_BOOKLET_SORT = "Arrange pages for sidestitch booklet printing (optimized for binding on the side)."
[pdfTextEditor]
tags = "edit text,modify text,change text,edit content,update text,rewrite,correct,amend,redline,revise,text editor,content editor"
conversionFailed = "Failed to convert PDF. Please try again."
converting = "Converting PDF to editable format..."
currentFile = "Current file: {{name}}"
@@ -5485,6 +5534,7 @@ imageLabel = "Placed image"
noTextOnPage = "No editable text was detected on this page."
pagePreviewAlt = "Page preview"
pageSummary = "Page {{number}} of {{total}}"
tags = "edit text,modify text,change text,edit content,update text,rewrite,correct,amend,redline,revise,text editor,content editor"
title = "PDF Text Editor"
viewLabel = "PDF Editor"
@@ -5646,9 +5696,9 @@ title = "PDF to Presentation"
tags = "single page"
[pdfToSinglePage]
tags = "combine,merge,single,single page,one page,merge to single,combine all,stitch pages,concatenate vertical,long page,poster"
description = "This tool will merge all pages of your PDF into one large single page. The width will remain the same as the original pages, but the height will be the sum of all page heights."
submit = "Convert To Single Page"
tags = "combine,merge,single,single page,one page,merge to single,combine all,stitch pages,concatenate vertical,long page,poster"
title = "PDF To Single Page"
[pdfToSinglePage.error]
@@ -5844,6 +5894,10 @@ subscribeToPro = "Subscribe to Pro"
subscriptionScheduled = "Subscription scheduled - starts {{date}}"
title = "Free Trial Active"
[policies]
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
deleteConfirmTitle = "Delete policy?"
[printFile]
title = "Print File"
@@ -6124,6 +6178,9 @@ whatsNewTourDesc = "Tour the updated layout"
admin = "Watch walkthroughs here: Tools tour, New V2 layout tour, and the Admin tour."
user = "Watch walkthroughs here: Tools tour and the New V2 layout tour."
[read]
tags = "view,open,display,read,viewer,PDF viewer,PDF reader,open PDF,view PDF,display PDF,preview,browse"
[redact]
submit = "Redact"
tags = "Redact,Hide,black out,black,marker,hidden,auto redact,manual redact"
@@ -6254,8 +6311,8 @@ title = "What it does"
title = "About Remove Annotations"
[removeBlanks]
tags = "delete,clean,empty,remove blank,delete blank pages,empty pages,white pages,remove empty,clean up,cleanup blank"
submit = "Remove blank pages"
tags = "delete,clean,empty,remove blank,delete blank pages,empty pages,white pages,remove empty,clean up,cleanup blank"
title = "Remove Blanks"
[removeBlanks.error]
@@ -6313,8 +6370,8 @@ failed = "An error occurred whilst removing certificate signatures."
title = "Certificate Removal Results"
[removeImage]
tags = "remove,delete,clean,remove image,delete image,strip images,remove pictures,delete photos,clean images,reduce size,remove graphics"
submit = "Remove image"
tags = "remove,delete,clean,remove image,delete image,strip images,remove pictures,delete photos,clean images,reduce size,remove graphics"
title = "Remove image"
[removeImage.error]
@@ -6385,8 +6442,8 @@ title = "Decrypted PDFs"
description = "Removing password protection requires the password that was used to encrypt the PDF. This will decrypt the document, making it accessible without a password."
[reorganizePages]
tags = "rearrange,reorder,organize,reorganize,move pages,page order,sort pages,arrange pages,shuffle,resequence"
submit = "Reorganize Pages"
tags = "rearrange,reorder,organize,reorganize,move pages,page order,sort pages,arrange pages,shuffle,resequence"
[reorganizePages.error]
failed = "Failed to reorganize pages"
@@ -6469,11 +6526,11 @@ text = "Completely invert all colours in the PDF, creating a negative-like effec
title = "Invert All Colours"
[rotate]
tags = "turn,flip,orient,rotate,orientation,landscape,portrait,90 degrees,180 degrees,clockwise,anticlockwise,counter-clockwise,fix orientation"
rotateLeft = "Rotate Anticlockwise"
rotateRight = "Rotate Clockwise"
selectRotation = "Select Rotation Angle (Clockwise)"
submit = "Apply Rotation"
tags = "turn,flip,orient,rotate,orientation,landscape,portrait,90 degrees,180 degrees,clockwise,anticlockwise,counter-clockwise,fix orientation"
title = "Rotate PDF"
[rotate.error]
@@ -6493,10 +6550,10 @@ text = "Rotate your PDF pages clockwise or anticlockwise in 90-degree increments
title = "Rotate Settings Overview"
[sanitize]
tags = "clean,purge,remove,sanitize,sanitise,remove scripts,remove javascript,remove metadata,strip metadata,security,clean document,remove hidden data,privacy"
desc = "Remove potentially harmful elements from PDF files."
sanitizationResults = "Sanitisation Results"
submit = "Sanitise PDF"
tags = "clean,purge,remove,sanitize,sanitise,remove scripts,remove javascript,remove metadata,strip metadata,security,clean document,remove hidden data,privacy"
title = "Sanitise"
[sanitize.error]
@@ -6543,6 +6600,9 @@ title = "Sanitise PDF"
tags = "resize,adjust,scale,page size,resize page,scale page,change size,adjust size,enlarge,shrink page,fit to page,A4,letter size"
title = "Adjust page-scale"
[scannerEffect]
tags = "scan,simulate,create,fake scan,look scanned,scanner effect,make look scanned,photocopy effect,simulate scanner,realistic scan"
[ScannerImageSplit.selectText]
1 = "Angle Threshold:"
10 = "Extra padding (in pixels) around each saved photo so edges aren't cut."
@@ -6967,7 +7027,6 @@ title = "Show Javascript"
title = "Extracted JavaScript"
[sign]
tags = "signature,autograph,e-sign,electronic signature,digital signature,sign document,approval,signoff,authorize,endorse,ink signature,handwriting"
activate = "Activate Signature Placement"
add = "Add"
addToAll = "Add to all pages"
@@ -6987,6 +7046,7 @@ redo = "Redo"
save = "Save Signature"
sharedSigs = "Shared Signatures"
submit = "Sign Document"
tags = "signature,autograph,e-sign,electronic signature,digital signature,sign document,approval,signoff,authorize,endorse,ink signature,handwriting"
title = "Sign"
undo = "Undo"
updateAndPlace = "Update and Place"
@@ -7174,11 +7234,11 @@ small = "Small"
x-large = "X-Large"
[split]
tags = "divide,separate,break,split,extract pages,separate pages,divide document,break apart,separate files,unbind,split by page,divide by chapter"
resultsTitle = "Split Results"
selectMethod = "Select a split method"
splitPages = "Enter pages to split on:"
submit = "Split"
tags = "divide,separate,break,split,extract pages,separate pages,divide document,break apart,separate files,unbind,split by page,divide by chapter"
title = "Split PDF"
[split.error]
@@ -7538,10 +7598,10 @@ justNow = "just now"
minutesAgo = "{{count}}m ago"
[timestampPdf]
tags = "timestamp,RFC 3161,TSA,time stamp authority,document timestamp,proof of existence,timestamp token,trusted timestamp,sign timestamp,notarise"
desc = "Add an RFC 3161 document timestamp to your PDF using a trusted Time Stamp Authority (TSA) server."
results = "Timestamp Results"
submit = "Apply Timestamp"
tags = "timestamp,RFC 3161,TSA,time stamp authority,document timestamp,proof of existence,timestamp token,trusted timestamp,sign timestamp,notarise"
title = "Timestamp PDF"
[timestampPdf.error]
@@ -7937,10 +7997,173 @@ title = "View/Edit PDF"
[warning]
tooltipTitle = "Warning"
[watchedFolders]
alreadyInFolder = "Already in this folder"
defaultFolderWarning = "This is a default folder and will be recreated on next reload."
deleteConfirmBody = "This will remove the folder and its run history. Files already downloaded are not affected."
deleteConfirmTitle = "Delete folder?"
folderNotFound = "Folder not found"
newFolder = "New folder"
noFolders = "No watched folders yet"
sidebarTitle = "Watched Folders"
title = "Watched Folders"
[watchedFolders.actions]
back = "Back"
collapse = "Collapse"
dismiss = "Dismiss"
download = "Download"
downloadInput = "Download input"
downloadOutput = "Download output"
expand = "Expand"
retry = "Retry"
[watchedFolders.card]
delete = "Delete folder"
edit = "Edit folder"
[watchedFolders.home]
addAnother = "Add another Watched Folder"
addAnotherDesc = "Automatically process files with a new pipeline"
create = "Create your first Watched Folder"
deleteFolder = "Delete folder"
editFolder = "Edit folder"
empty = "No watched folders yet"
emptyDesc = "Set up a Watched Folder once. Drop PDFs in and they're automatically compressed, OCR'd, split, merged — whatever your pipeline does."
emptyTitle = "Automate your PDF workflows"
file = "file"
files = "files"
pause = "Pause"
resume = "Resume"
[watchedFolders.howItWorks]
step1Desc = "Drag PDFs onto any Watched Folder card — or send them from your file list"
step1Title = "Drop files"
step2Desc = "Your configured tools process each file automatically"
step2Title = "Pipeline runs"
step3Desc = "Download processed files from inside the folder"
step3Title = "Output ready"
title = "How Watched Folders work"
[watchedFolders.modal]
advanced = "Advanced"
automation = "Automation"
automationNameFallback = "Watched Folder automation"
automationRequired = "Add at least one configured step before saving."
autoNumber = "Auto-number"
autoNumberExample = "e.g. document.pdf → document (1).pdf"
autoScanHelp = "New PDF files in this folder are processed automatically every 10 seconds."
changeFolder = "Change"
chooseFolder = "Choose"
clearFolder = "Clear"
color = "Accent color"
createFolder = "Create Folder"
createTitle = "New Watched Folder"
editTitle = "Edit Watched Folder"
inputFolder = "Input folder"
inputFolderNotChosen = "No folder chosen — required for auto-scan"
inputSource = "Input source"
inputSourceBrowser = "Browser — drop files here"
inputSourceLocal = "Local folder (auto-scan)"
inputSourceLocalUnsupported = "Local folder (auto-scan) — Chrome/Edge only"
localOutputFolder = "Local output folder"
maxRetries = "Max retries"
name = "Folder name"
namePlaceholder = "My Watched Folder"
nameRequired = "Folder name is required"
nameTooLong = "Folder name must be 50 characters or less"
outputFolderNotSet = "Not set — outputs stay in app"
outputFolderUnsupported = "Not supported in this browser"
outputModeNewDesc = "Output is saved as a new separate file"
outputModeVersion = "Replace original?"
outputModeVersionDesc = "Output becomes a new version of the input file rather than a separate file"
outputName = "Output filename prefix"
outputNamePrefix = "Filename prefix"
outputNameSuffix = "Suffix"
positionPrefix = "Prefix"
positionSuffix = "Suffix"
replaceOriginal = "Replace original file"
retryDelay = "Delay (minutes)"
saveChanges = "Save Changes"
saveFailed = "Failed to save folder. Please try again."
sectionFolder = "Folder"
sectionSourceOutput = "Source & Output"
sectionSteps = "Steps"
[watchedFolders.status]
active = "Active"
done = "Done"
paused = "Paused"
processing = "Processing"
[watchedFolders.time]
daysAgo = "{{count}}d ago"
hoursAgo = "{{count}}h ago"
justNow = "just now"
minutesAgo = "{{count}}m ago"
[watchedFolders.workbench]
activity = "Activity"
addFiles = "Add files"
allTime = "All time"
cancel = "Cancel"
chartTooltipComplete = "{{count}} complete"
chartTooltipFailed = "{{count}} failed"
clearSelection = "Clear selection"
countProcessing = "{{count}} processing"
countQueued = "{{count}} queued"
countSelected = "{{count}} selected"
dataSaved = "Saved"
dayRunning = "day running"
daysRunning = "days running"
delete = "Delete"
deleteConfirmBody = "Remove notifications only clears the activity log. Delete outputs also removes the processed files from storage. Your original input files are never touched."
deleteOutputs = "Delete outputs"
directionIn = "in"
directionOut = "out"
dropToProcess = "Drop to process"
export = "Export"
exportSeparately = "Export separately"
exportZip = "Export zip"
failed = "Failed"
filesProcessedOverTime = "Files processed over time"
filterAll = "All"
filterPending = "Pending"
inputs = "Inputs"
inputSize = "Input size"
legendComplete = "Complete"
noActivity = "No activity yet — drop a PDF to start"
noActivityMatch = "No matching activity"
noInputFolder = "No input folder — edit to configure"
outputs = "Outputs"
pause = "Pause"
preview = "Preview"
previewInput = "Preview input"
previewLoadFailed = "Could not load file preview."
previewOutput = "Preview output"
queued = "Queued"
queuedWaiting = "Queued — waiting to run"
removeEntries = "Remove {{count}} entries"
removeEntry = "Remove entry"
removeNotificationsOnly = "Remove notifications only"
resume = "Resume"
retryAll = "Retry all"
retryIn = "retry in {{count}}m"
retryingSoon = "retrying…"
retryMixedConfirm = "Some selected files have already completed and will be skipped. Only failed files will be retried. Continue?"
running = "Running"
search = "Search…"
selectAll = "Select all {{count}}"
sortNameAsc = "A → Z"
sortNameDesc = "Z → A"
sortNewest = "Newest"
sortOldest = "Oldest"
watching = "Watching: {{name}}"
[watermark]
tags = "stamp,mark,overlay,watermark,branding,logo,confidential,draft,copyright,trademark,text overlay,image overlay,background text"
desc = "Add text or image watermarks to PDF files"
submit = "Add Watermark"
tags = "stamp,mark,overlay,watermark,branding,logo,confidential,draft,copyright,trademark,text overlay,image overlay,background text"
title = "Add Watermark"
[watermark.error]
@@ -8401,189 +8624,3 @@ cancel = "Cancel"
confirm = "Extract"
message = "This ZIP contains {{count}} files. Extract anyway?"
title = "Large ZIP File"
[watchedFolders]
alreadyInFolder = "Already in this folder"
deleteConfirmBody = "This will remove the folder and its run history. Files already downloaded are not affected."
deleteConfirmTitle = "Delete folder?"
defaultFolderWarning = "This is a default folder and will be recreated on next reload."
folderNotFound = "Folder not found"
newFolder = "New folder"
noFolders = "No watched folders yet"
sidebarTitle = "Watched Folders"
title = "Watched Folders"
[watchedFolders.modal]
automation = "Automation"
createFolder = "Create Folder"
automationRequired = "Add at least one configured step before saving."
color = "Accent color"
createTitle = "New Watched Folder"
editTitle = "Edit Watched Folder"
name = "Folder name"
namePlaceholder = "My Watched Folder"
nameRequired = "Folder name is required"
nameTooLong = "Folder name must be 50 characters or less"
saveChanges = "Save Changes"
saveFailed = "Failed to save folder. Please try again."
automationNameFallback = "Watched Folder automation"
maxRetries = "Max retries"
retryDelay = "Delay (minutes)"
outputModeVersion = "Replace original?"
outputModeVersionDesc = "Output becomes a new version of the input file rather than a separate file"
outputModeNewDesc = "Output is saved as a new separate file"
outputName = "Output filename prefix"
outputNameSuffix = "Suffix"
sectionFolder = "Folder"
sectionSourceOutput = "Source & Output"
sectionSteps = "Steps"
inputSource = "Input source"
inputSourceBrowser = "Browser — drop files here"
inputSourceLocal = "Local folder (auto-scan)"
inputSourceLocalUnsupported = "Local folder (auto-scan) — Chrome/Edge only"
inputFolder = "Input folder"
inputFolderNotChosen = "No folder chosen — required for auto-scan"
changeFolder = "Change"
chooseFolder = "Choose"
clearFolder = "Clear"
autoScanHelp = "New PDF files in this folder are processed automatically every 10 seconds."
localOutputFolder = "Local output folder"
outputFolderUnsupported = "Not supported in this browser"
outputFolderNotSet = "Not set — outputs stay in app"
advanced = "Advanced"
replaceOriginal = "Replace original file"
autoNumber = "Auto-number"
autoNumberExample = "e.g. document.pdf → document (1).pdf"
outputNamePrefix = "Filename prefix"
positionPrefix = "Prefix"
positionSuffix = "Suffix"
[watchedFolders.home]
create = "Create your first Watched Folder"
editFolder = "Edit folder"
empty = "No watched folders yet"
file = "file"
files = "files"
emptyTitle = "Automate your PDF workflows"
emptyDesc = "Set up a Watched Folder once. Drop PDFs in and they're automatically compressed, OCR'd, split, merged — whatever your pipeline does."
addAnother = "Add another Watched Folder"
addAnotherDesc = "Automatically process files with a new pipeline"
resume = "Resume"
pause = "Pause"
deleteFolder = "Delete folder"
[watchedFolders.card]
edit = "Edit folder"
delete = "Delete folder"
[watchedFolders.status]
done = "Done"
processing = "Processing"
paused = "Paused"
active = "Active"
[watchedFolders.howItWorks]
title = "How Watched Folders work"
step1Title = "Drop files"
step1Desc = "Drag PDFs onto any Watched Folder card — or send them from your file list"
step2Title = "Pipeline runs"
step2Desc = "Your configured tools process each file automatically"
step3Title = "Output ready"
step3Desc = "Download processed files from inside the folder"
[watchedFolders.workbench]
addFiles = "Add files"
inputs = "Inputs"
outputs = "Outputs"
failed = "Failed"
dataSaved = "Saved"
running = "Running"
dropToProcess = "Drop to process"
activity = "Activity"
noActivity = "No activity yet — drop a PDF to start"
noActivityMatch = "No matching activity"
retryAll = "Retry all"
retryIn = "retry in {{count}}m"
retryingSoon = "retrying…"
pause = "Pause"
resume = "Resume"
dayRunning = "day running"
daysRunning = "days running"
sortNewest = "Newest"
sortOldest = "Oldest"
sortNameAsc = "A → Z"
sortNameDesc = "Z → A"
filterAll = "All"
filterPending = "Pending"
search = "Search…"
retryMixedConfirm = "Some selected files have already completed and will be skipped. Only failed files will be retried. Continue?"
watching = "Watching: {{name}}"
noInputFolder = "No input folder — edit to configure"
countProcessing = "{{count}} processing"
countQueued = "{{count}} queued"
exportZip = "Export zip"
exportSeparately = "Export separately"
countSelected = "{{count}} selected"
selectAll = "Select all {{count}}"
delete = "Delete"
clearSelection = "Clear selection"
preview = "Preview"
export = "Export"
previewInput = "Preview input"
previewOutput = "Preview output"
directionIn = "in"
directionOut = "out"
queuedWaiting = "Queued — waiting to run"
allTime = "All time"
queued = "Queued"
inputSize = "Input size"
filesProcessedOverTime = "Files processed over time"
legendComplete = "Complete"
chartTooltipComplete = "{{count}} complete"
chartTooltipFailed = "{{count}} failed"
removeEntry = "Remove entry"
removeEntries = "Remove {{count}} entries"
deleteConfirmBody = "Remove notifications only clears the activity log. Delete outputs also removes the processed files from storage. Your original input files are never touched."
cancel = "Cancel"
removeNotificationsOnly = "Remove notifications only"
deleteOutputs = "Delete outputs"
previewLoadFailed = "Could not load file preview."
[watchedFolders.actions]
back = "Back"
dismiss = "Dismiss"
retry = "Retry"
download = "Download"
downloadInput = "Download input"
downloadOutput = "Download output"
collapse = "Collapse"
expand = "Expand"
[watchedFolders.time]
justNow = "just now"
minutesAgo = "{{count}}m ago"
hoursAgo = "{{count}}h ago"
daysAgo = "{{count}}d ago"
[addStamp]
tags = "stamp,mark,seal,approved,rejected,confidential,stamp tool,rubber stamp,date stamp,approval stamp,received,void,copy,original"
[annotate]
tags = "annotate,highlight,draw,markup,comment,notes,review,redline,feedback,markup tools,sticky notes,shapes,arrows,text box,freehand"
[devAirgapped]
tags = "air-gapped,offline,isolated,no internet,disconnected,secure,network isolation,standalone"
[devApi]
tags = "API,development,documentation,developer,REST,integration,endpoints,programmatic,automation,scripting"
[devFolderScanning]
tags = "automation,folder,scanning,watch folder,hot folder,automatic processing,batch,monitor folder,auto process,folder monitoring"
[devSsoGuide]
tags = "SSO,single sign-on,authentication,SAML,OAuth,OIDC,login,enterprise,identity provider,IdP"
[read]
tags = "view,open,display,read,viewer,PDF viewer,PDF reader,open PDF,view PDF,display PDF,preview,browse"
[scannerEffect]
tags = "scan,simulate,create,fake scan,look scanned,scanner effect,make look scanned,photocopy effect,simulate scanner,realistic scan"
@@ -14,6 +14,7 @@ export const AUTH_ROUTES = [
"/invite",
"/forgot-password",
"/reset-password",
"/oauth/consent",
];
/**
@@ -30,6 +30,7 @@ interface McpAuthData {
issuerUri?: string;
jwksUri?: string;
resourceId?: string;
acceptedAudiences?: string[];
usernameClaim?: string;
requireExistingAccount?: boolean;
}
@@ -96,6 +97,7 @@ export default function AdminMcpSection() {
"mcp.auth.issuerUri": s.auth?.issuerUri ?? "",
"mcp.auth.jwksUri": s.auth?.jwksUri ?? "",
"mcp.auth.resourceId": s.auth?.resourceId ?? "",
"mcp.auth.acceptedAudiences": s.auth?.acceptedAudiences ?? [],
"mcp.auth.usernameClaim": s.auth?.usernameClaim ?? "sub",
"mcp.auth.requireExistingAccount":
s.auth?.requireExistingAccount ?? true,
@@ -269,6 +271,32 @@ export default function AdminMcpSection() {
disabled={!settings.enabled}
/>
<TextInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.mcp.acceptedAudiences.label",
"Additional accepted audiences (optional)",
)}
</span>
<PendingBadge
show={isFieldPending("auth.acceptedAudiences")}
/>
</Group>
}
description={t(
"admin.settings.mcp.acceptedAudiences.description",
"Extra token audience values accepted besides the Resource ID (comma or space separated). Leave blank for strict RFC 8707. Needed for IdPs that cannot mint resource audiences - e.g. Supabase's OAuth server always issues aud=authenticated.",
)}
value={(settings.auth?.acceptedAudiences || []).join(" ")}
onChange={(e) =>
setAuth({ acceptedAudiences: parseOpList(e.target.value) })
}
placeholder="authenticated"
disabled={!settings.enabled}
/>
<TextInput
label={
<Group gap="xs">
+24 -4
View File
@@ -1,5 +1,6 @@
import { Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import { Routes, Route, useLocation } from "react-router-dom";
import { isAuthRoute } from "@app/utils/pathUtils";
import { AppProviders } from "@app/components/AppProviders";
import { setBaseUrl } from "@app/constants/app";
import type { AppConfig } from "@app/contexts/AppConfigContext";
@@ -11,6 +12,7 @@ import Login from "@app/routes/Login";
import Signup from "@app/routes/Signup";
import AuthCallback from "@app/routes/AuthCallback";
import ResetPassword from "@app/routes/ResetPassword";
import OAuthConsent from "@app/routes/OAuthConsent";
import OnboardingBootstrap from "@app/components/OnboardingBootstrap";
import TrialExpiredBootstrap from "@app/components/TrialExpiredBootstrap";
import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap";
@@ -28,6 +30,25 @@ function handleConfigLoaded(config: AppConfig) {
if (config.baseUrl) setBaseUrl(config.baseUrl);
}
/**
* Onboarding and trial-expired modals must never cover auth-flow pages
* (login, signup, OAuth consent): they steal focus from the task the user
* was sent there to complete. Unmounting also stops their background polling.
*/
function NonAuthBootstraps() {
const location = useLocation();
if (isAuthRoute(location.pathname)) {
return null;
}
return (
<>
<OnboardingBootstrap />
<TrialExpiredBootstrap />
<SignupRequiredBootstrap />
</>
);
}
export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
@@ -35,14 +56,13 @@ export default function App() {
appConfigProviderProps={{ onConfigLoaded: handleConfigLoaded }}
>
<AppLayout>
<OnboardingBootstrap />
<TrialExpiredBootstrap />
<SignupRequiredBootstrap />
<NonAuthBootstraps />
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/auth/reset" element={<ResetPassword />} />
<Route path="/oauth/consent" element={<OAuthConsent />} />
<Route path="/*" element={<Landing />} />
</Routes>
<OnboardingTour />
+30 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { supabase, signInAnonymously } from "@app/auth/supabase";
import { useAuth } from "@app/auth/UseSession";
@@ -47,6 +47,25 @@ export default function Login() {
}
}, []);
// Same-origin relative path to return to after login (e.g. the OAuth
// consent page). Same sanitization rules as AuthCallback's `next`.
const nextPath = useMemo(() => {
try {
const next = new URL(window.location.href).searchParams.get("next");
return next && next.startsWith("/") && !next.startsWith("//")
? next
: null;
} catch (_) {
return null;
}
}, []);
useEffect(() => {
if (session && !loading && nextPath) {
navigate(nextPath, { replace: true });
}
}, [session, loading, nextPath, navigate]);
const baseUrl = getBaseUrl();
// Set document meta
@@ -65,8 +84,11 @@ export default function Login() {
ogUrl: `${window.location.origin}${window.location.pathname}`,
});
// Show logged in state if authenticated
// Show logged in state if authenticated (unless bouncing back to `next`)
if (session && !loading) {
if (nextPath) {
return null;
}
return <LoggedInState />;
}
@@ -77,7 +99,9 @@ export default function Login() {
setIsSigningIn(true);
setError(null);
const redirectTo = absoluteWithBasePath("/auth/callback");
const redirectTo =
absoluteWithBasePath("/auth/callback") +
(nextPath ? `?next=${encodeURIComponent(nextPath)}` : "");
console.log(`[Login] Signing in with ${provider}`);
const oauthOptions: {
@@ -169,7 +193,9 @@ export default function Login() {
const { error } = await supabase.auth.signInWithOtp({
email: magicLinkEmail.trim(),
options: {
emailRedirectTo: absoluteWithBasePath("/auth/callback"),
emailRedirectTo:
absoluteWithBasePath("/auth/callback") +
(nextPath ? `?next=${encodeURIComponent(nextPath)}` : ""),
},
});
@@ -0,0 +1,410 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "@app/auth/UseSession";
import { useTranslation } from "@app/hooks/useTranslation";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import "@app/routes/authShared/auth.css";
import "@app/routes/authShared/saas-auth.css";
import { withBasePath } from "@app/constants/app";
import ErrorMessage from "@app/routes/login/ErrorMessage";
/**
* OAuth 2.1 consent screen for the Supabase OAuth server (used by MCP clients
* such as Claude). Supabase redirects the user here as
* {SiteURL}/oauth/consent?authorization_id=X; this page loads the pending
* authorization, shows what the third-party app is asking for, and forwards
* the user's approve/deny decision back to Supabase, which then redirects to
* the requesting app with an authorization code (or an error).
*
* The installed supabase-js does not yet wrap these endpoints, so the GoTrue
* REST API is called directly with the user's session token.
*/
interface AuthorizationDetails {
authorization_id: string;
redirect_uri?: string;
scope?: string;
client?: {
id?: string;
name?: string;
logo_uri?: string;
};
}
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL as string;
const SUPABASE_KEY = import.meta.env
.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY as string;
async function gotrue(
path: string,
accessToken: string,
init?: RequestInit,
): Promise<Response> {
return fetch(`${SUPABASE_URL}/auth/v1/${path}`, {
...init,
headers: {
apikey: SUPABASE_KEY,
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
...(init?.headers || {}),
},
});
}
export default function OAuthConsent() {
const navigate = useNavigate();
const location = useLocation();
const { session, loading: sessionLoading, displayName } = useAuth();
const { t } = useTranslation();
const [details, setDetails] = useState<AuthorizationDetails | null>(null);
const [loadingDetails, setLoadingDetails] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deciding, setDeciding] = useState<"approve" | "deny" | null>(null);
const [redirecting, setRedirecting] = useState(false);
const authorizationId = useMemo(() => {
try {
return new URLSearchParams(location.search).get("authorization_id");
} catch (_) {
return null;
}
}, [location.search]);
useDocumentMeta({
title: `${t("oauthConsent.title", "Authorize access")} - Stirling PDF`,
});
// Load the pending authorization once a session is available.
useEffect(() => {
if (sessionLoading || !session || !authorizationId) {
return;
}
let cancelled = false;
(async () => {
try {
setLoadingDetails(true);
const response = await gotrue(
`oauth/authorizations/${encodeURIComponent(authorizationId)}`,
session.access_token,
);
const body = await response.json().catch(() => ({}));
if (cancelled) return;
if (!response.ok) {
console.error("[OAuthConsent] Failed to load authorization:", body);
setError(
t(
"oauthConsent.loadFailed",
"This authorization request is invalid or has expired. Close this tab and try connecting again from the app.",
),
);
} else {
setDetails(body as AuthorizationDetails);
}
} catch (err) {
console.error("[OAuthConsent] Unexpected error:", err);
if (!cancelled) {
setError(
t(
"oauthConsent.loadFailed",
"This authorization request is invalid or has expired. Close this tab and try connecting again from the app.",
),
);
}
} finally {
if (!cancelled) setLoadingDetails(false);
}
})();
return () => {
cancelled = true;
};
}, [sessionLoading, session, authorizationId, t]);
const decide = useCallback(
async (action: "approve" | "deny") => {
if (!session || !authorizationId || deciding) return;
try {
setDeciding(action);
setError(null);
const response = await gotrue(
`oauth/authorizations/${encodeURIComponent(authorizationId)}/consent`,
session.access_token,
{ method: "POST", body: JSON.stringify({ action }) },
);
const body = await response.json().catch(() => ({}));
if (!response.ok || !body.redirect_url) {
console.error("[OAuthConsent] Consent call failed:", body);
setError(
t(
"oauthConsent.decisionFailed",
"Could not submit your decision. Please try again.",
),
);
setDeciding(null);
return;
}
// Send the browser back to the requesting app with the code (or error).
setRedirecting(true);
window.location.assign(body.redirect_url as string);
} catch (err) {
console.error("[OAuthConsent] Unexpected error:", err);
setError(
t(
"oauthConsent.decisionFailed",
"Could not submit your decision. Please try again.",
),
);
setDeciding(null);
}
},
[session, authorizationId, deciding, t],
);
const appName =
details?.client?.name ||
t("oauthConsent.unknownApp", "A third-party application");
const scopes = (details?.scope || "")
.split(/\s+/)
.map((s) => s.trim())
.filter(Boolean);
const scopeDescription = (scope: string): string => {
switch (scope) {
case "openid":
return t("oauthConsent.scope.openid", "Confirm your identity");
case "email":
return t("oauthConsent.scope.email", "See your email address");
case "profile":
return t(
"oauthConsent.scope.profile",
"See your basic profile information",
);
case "phone":
return t("oauthConsent.scope.phone", "See your phone number");
default:
return scope;
}
};
const logoBlock = (
<div className="auth-logo-block">
<img
src={withBasePath("/modern-logo/LoginLightModeHeader.svg")}
alt="Stirling PDF"
className="auth-logo-header auth-logo-header--light"
/>
<img
src={withBasePath("/modern-logo/LoginDarkModeHeader.svg")}
alt="Stirling PDF"
className="auth-logo-header auth-logo-header--dark"
/>
</div>
);
// Missing query parameter: nothing to act on.
if (!authorizationId) {
return (
<AuthLayout>
{logoBlock}
<ErrorMessage
error={t(
"oauthConsent.missingId",
"Missing authorization request. Start the connection from your app and try again.",
)}
/>
</AuthLayout>
);
}
// Not signed in: round-trip through login and come back here.
if (!sessionLoading && !session) {
const next = `${location.pathname}${location.search}`;
return (
<AuthLayout>
{logoBlock}
<p
style={{
textAlign: "center",
marginBottom: "1.5rem",
color: "#374151",
}}
>
{t(
"oauthConsent.signInPrompt",
"Sign in to your Stirling PDF account to continue connecting the app.",
)}
</p>
<button
type="button"
className="oauth-button-fullwidth"
onClick={() => navigate(`/login?next=${encodeURIComponent(next)}`)}
>
{t("oauthConsent.signInButton", "Sign in to continue")}
</button>
</AuthLayout>
);
}
if (sessionLoading || loadingDetails || redirecting) {
return (
<AuthLayout>
{logoBlock}
<p style={{ textAlign: "center", color: "#6b7280" }}>
{redirecting
? t("oauthConsent.redirecting", "Returning you to the app...")
: t("oauthConsent.loading", "Loading authorization request...")}
</p>
</AuthLayout>
);
}
if (error && !details) {
return (
<AuthLayout>
{logoBlock}
<ErrorMessage error={error} />
</AuthLayout>
);
}
return (
<AuthLayout>
{logoBlock}
{/* AuthLayout forces light mode but text without explicit colors still
inherits dark-scheme values from the app CSS; pin them like the rest
of the auth pages do. */}
<h2
style={{
textAlign: "center",
fontSize: "1.25rem",
fontWeight: 700,
marginBottom: "0.5rem",
color: "#111827",
}}
>
{t("oauthConsent.title", "Authorize access")}
</h2>
<p
style={{
textAlign: "center",
marginBottom: "1.5rem",
color: "#374151",
}}
>
{t("oauthConsent.requesting", {
app: appName,
defaultValue: `${appName} wants to access your Stirling PDF account`,
})}
</p>
{/* Be explicit about what connecting actually grants. The OAuth scopes
(openid/email) only cover identity; the real power is that the issued
token lets the app drive the MCP endpoint - i.e. run any Stirling PDF
tool as this user, audited as them and counted against their usage. */}
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: "0.5rem",
padding: "1rem 1.25rem",
marginBottom: "1.5rem",
background: "#ffffff",
}}
>
<p
style={{
fontSize: "0.875rem",
fontWeight: 600,
margin: "0 0 0.5rem",
color: "#111827",
}}
>
{t("oauthConsent.scopesIntro", {
app: appName,
defaultValue: `This will allow ${appName} to:`,
})}
</p>
<ul
style={{
margin: 0,
paddingLeft: "1.25rem",
display: "flex",
flexDirection: "column",
gap: "0.25rem",
}}
>
<li style={{ fontSize: "0.875rem", color: "#374151" }}>
{t("oauthConsent.access.tools", {
app: appName,
defaultValue: `Use your Stirling PDF tools on your behalf - convert, edit, sign, secure and process your documents`,
})}
</li>
<li style={{ fontSize: "0.875rem", color: "#374151" }}>
{t("oauthConsent.access.actAsYou", {
app: appName,
defaultValue: `Act as you - everything ${appName} does runs under your account and counts towards your usage`,
})}
</li>
{scopes.map((scope) => (
<li key={scope} style={{ fontSize: "0.875rem", color: "#374151" }}>
{scopeDescription(scope)}
</li>
))}
</ul>
</div>
<ErrorMessage error={error} />
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<button
type="button"
disabled={deciding !== null}
onClick={() => decide("approve")}
style={{
width: "100%",
padding: "0.75rem",
borderRadius: "0.5rem",
border: "none",
background: "#000000",
color: "#ffffff",
fontWeight: 700,
fontSize: "1rem",
cursor: deciding ? "default" : "pointer",
opacity: deciding && deciding !== "approve" ? 0.6 : 1,
}}
>
{deciding === "approve"
? t("oauthConsent.approving", "Allowing...")
: t("oauthConsent.approve", "Allow access")}
</button>
<button
type="button"
disabled={deciding !== null}
onClick={() => decide("deny")}
className="oauth-button-fullwidth"
>
{deciding === "deny"
? t("oauthConsent.denying", "Denying...")
: t("oauthConsent.deny", "Deny")}
</button>
</div>
{displayName && (
<p
style={{
textAlign: "center",
fontSize: "0.8125rem",
color: "#9ca3af",
marginTop: "1.25rem",
}}
>
{t("oauthConsent.signedInAs", {
name: displayName,
defaultValue: `Signed in as ${displayName}`,
})}
</p>
)}
</AuthLayout>
);
}
@@ -115,14 +115,13 @@ apiClient.interceptors.request.use(
},
);
// List of endpoints that don't require authentication
const publicEndpoints = [
"/api/v1/config/app-config",
"/api/v1/info/status",
"/api/v1/config/public-config",
"/api/v1/config/endpoints-availability",
"/api/v1/config/endpoint-enabled",
];
// Endpoints whose 401s must never trigger the hard redirect to /login. Prefix
// matching covers the whole public surface (/api/v1/config/* and /api/v1/info/*
// are permitAll on the backend), and crucially includes background pings like
// /api/v1/info/wau that fire before session hydration - without this they bounce
// the user off pages that manage their own auth state (e.g. /oauth/consent) and
// lose URL state.
const publicEndpoints = ["/api/v1/config/", "/api/v1/info/"];
// Share one in-flight refresh: Supabase rotates the refresh token on first
// use, so concurrent refreshSession() calls fail with "Already Used".
+7 -1
View File
@@ -25,7 +25,13 @@ export function normalizePath(pathname: string): string {
*/
export function isAuthRoute(pathname: string): boolean {
const p = normalizePath(pathname);
return p === "/login" || p === "/signup" || p === "/auth/callback";
return (
p === "/login" ||
p === "/signup" ||
p === "/auth/callback" ||
p === "/auth/reset" ||
p === "/oauth/consent"
);
}
/**