@@ -25,8 +25,9 @@ import { useObjectTranslation } from '@object-ui/react';
2525 * Storage model: one `sys_file` row per uploaded blob (three-step
2626 * presigned upload via @object-ui/providers' ObjectStack adapter), one
2727 * `sys_attachment` join row linking it to `(parent_object, parent_id)`.
28- * Downloads go through the stable `/storage/files/:fileId` endpoint,
29- * which 302-redirects to a freshly signed URL on every request.
28+ * Downloads fetch a short-lived signed URL from `/storage/files/:fileId/url`
29+ * with the console's Bearer token (the endpoint requires an authenticated
30+ * session for attachments-scope files, #2970), then open it.
3031 */
3132
3233interface AttachmentRow {
@@ -74,16 +75,16 @@ export const RecordAttachmentsPanel: React.FC<RecordAttachmentsPanelProps> = ({
7475 // Vite dev console proxies same-origin `/api` unless VITE_SERVER_URL
7576 // points elsewhere.
7677 const baseUrl = ( import . meta as any ) . env ?. VITE_SERVER_URL || '' ;
78+ // One authenticated fetch (Bearer token from localStorage) reused for the
79+ // upload adapter and the download-URL fetch — the storage routes require a
80+ // session and there is no cookie for `credentials: 'include'` to carry.
81+ const authFetch = React . useMemo ( ( ) => createAuthenticatedFetch ( ) , [ ] ) ;
7782 const adapter = React . useMemo (
78- // `fetchImpl`: the storage upload routes require an authenticated
79- // session when the server has an auth service (#2755), and the console
80- // authenticates with a Bearer token (localStorage) — there is no session
81- // cookie for `credentials: 'include'` to carry.
82- ( ) => createObjectStackUploadAdapter ( { baseUrl, scope : 'attachments' , fetchImpl : createAuthenticatedFetch ( ) } ) ,
83- [ baseUrl ] ,
83+ ( ) => createObjectStackUploadAdapter ( { baseUrl, scope : 'attachments' , fetchImpl : authFetch } ) ,
84+ [ baseUrl , authFetch ] ,
8485 ) ;
8586
86- /** Map the server's fail-closed 403 codes (#2755) to friendly copy. */
87+ /** Map the server's fail-closed 40x codes (#2755, #2970 ) to friendly copy. */
8788 const friendlyError = React . useCallback (
8889 ( err : unknown ) : string => {
8990 const anyErr = err as { code ?: string ; message ?: unknown } | null ;
@@ -99,6 +100,16 @@ export const RecordAttachmentsPanel: React.FC<RecordAttachmentsPanelProps> = ({
99100 defaultValue : "You don't have access to attach files to this record." ,
100101 } ) ;
101102 }
103+ if ( has ( 'ATTACHMENT_DOWNLOAD_DENIED' ) ) {
104+ return t ( 'detail.attachmentDownloadDenied' , {
105+ defaultValue : "You don't have access to download this attachment." ,
106+ } ) ;
107+ }
108+ if ( has ( 'AUTH_REQUIRED' ) ) {
109+ return t ( 'detail.attachmentAuthRequired' , {
110+ defaultValue : 'Please sign in to download this attachment.' ,
111+ } ) ;
112+ }
102113 if ( has ( 'PERMISSION_DENIED' ) ) {
103114 return t ( 'detail.attachmentPermissionDenied' , {
104115 defaultValue : "You don't have permission to do that." ,
@@ -186,6 +197,38 @@ export const RecordAttachmentsPanel: React.FC<RecordAttachmentsPanelProps> = ({
186197 [ dataSource , friendlyError ] ,
187198 ) ;
188199
200+ const handleDownload = React . useCallback (
201+ async ( row : AttachmentRow ) => {
202+ setError ( null ) ;
203+ try {
204+ // The stable `/files/:fileId` endpoint now requires an authenticated
205+ // session for attachments-scope files (#2970) — an <a href> can't
206+ // carry the Bearer token. Fetch a short-lived signed URL with auth,
207+ // then open it (the signed URL itself needs no credentials).
208+ const res = await authFetch (
209+ `${ baseUrl } /api/v1/storage/files/${ encodeURIComponent ( row . file_id ) } /url` ,
210+ ) ;
211+ if ( ! res . ok ) {
212+ let code : string | undefined ;
213+ try {
214+ code = ( await res . json ( ) ) ?. code ;
215+ } catch {
216+ /* non-JSON body */
217+ }
218+ throw Object . assign ( new Error ( code ?? `Download failed (${ res . status } )` ) , { code } ) ;
219+ }
220+ const body = await res . json ( ) ;
221+ const url : string | undefined = body ?. url ?? body ?. data ?. url ;
222+ if ( ! url ) throw new Error ( 'Download URL missing from response' ) ;
223+ const target = / ^ h t t p s ? : / i. test ( url ) ? url : `${ baseUrl } ${ url } ` ;
224+ window . open ( target , '_blank' , 'noopener,noreferrer' ) ;
225+ } catch ( err : any ) {
226+ setError ( friendlyError ( err ) ) ;
227+ }
228+ } ,
229+ [ authFetch , baseUrl , friendlyError ] ,
230+ ) ;
231+
189232 return (
190233 < div className = { cn ( 'rounded-lg border bg-card' , className ) } data-testid = "record-attachments-panel" >
191234 < div className = "flex items-center justify-between px-4 py-3 border-b" >
@@ -248,17 +291,15 @@ export const RecordAttachmentsPanel: React.FC<RecordAttachmentsPanelProps> = ({
248291 . join ( ' · ' ) }
249292 </ div >
250293 </ div >
251- < a
252- href = { `${ baseUrl } /api/v1/storage/files/${ encodeURIComponent ( row . file_id ) } ` }
253- target = "_blank"
254- rel = "noreferrer"
255- className = "inline-flex"
294+ < Button
295+ variant = "ghost"
296+ size = "icon"
297+ className = "h-8 w-8"
256298 aria-label = { t ( 'detail.downloadAttachment' , { defaultValue : 'Download' } ) }
299+ onClick = { ( ) => void handleDownload ( row ) }
257300 >
258- < Button variant = "ghost" size = "icon" className = "h-8 w-8" >
259- < Download className = "h-4 w-4" />
260- </ Button >
261- </ a >
301+ < Download className = "h-4 w-4" />
302+ </ Button >
262303 < Button
263304 variant = "ghost"
264305 size = "icon"
0 commit comments