@@ -15,12 +15,21 @@ import { Field, FieldContent, FieldLabel } from "@/components/ui/field"
1515import { RadioGroup , RadioGroupItem } from "@/components/ui/radio-group"
1616import { Tabs , TabsContent , TabsList , TabsTrigger } from "@/components/ui/tabs"
1717import { CopyInput } from "@/components/copy-input"
18+ import { DateTimePicker } from "@/components/datetime-picker"
1819import { useObject } from "@/hooks/use-object"
1920import { usePermissions } from "@/hooks/use-permissions"
2021import { useMessage } from "@/lib/feedback/message"
2122import { useDialog } from "@/lib/feedback/dialog"
2223import { exportFile } from "@/lib/export-file"
2324import { getContentType } from "@/lib/mime-types"
25+ import {
26+ getDefaultObjectRetentionDate ,
27+ getMinimumObjectRetentionDate ,
28+ isObjectLegalHoldEnabled ,
29+ isObjectRetentionDateInFuture ,
30+ shouldShowObjectRetentionAction ,
31+ toObjectRetentionRequestValue ,
32+ } from "@/lib/object-lock.js"
2433import { ObjectVersions } from "@/components/object/versions"
2534import { GetObjectCommand , HeadObjectCommand } from "@aws-sdk/client-s3"
2635import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
@@ -52,6 +61,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
5261 const [ retention , setRetention ] = React . useState ( "" )
5362 const [ retainUntilDate , setRetainUntilDate ] = React . useState ( "" )
5463 const [ retentionMode , setRetentionMode ] = React . useState < "COMPLIANCE" | "GOVERNANCE" > ( "GOVERNANCE" )
64+ const [ minRetentionDate , setMinRetentionDate ] = React . useState ( ( ) => getMinimumObjectRetentionDate ( ) )
5565 const [ signedUrl , setSignedUrl ] = React . useState ( "" )
5666 const [ showTagView , setShowTagView ] = React . useState ( false )
5767 const [ showRetentionView , setShowRetentionView ] = React . useState ( false )
@@ -67,6 +77,8 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
6777 const [ totalExpirationSeconds , setTotalExpirationSeconds ] = React . useState ( 0 )
6878 const [ isExpirationValid , setIsExpirationValid ] = React . useState ( false )
6979 const [ isGeneratingUrl , setIsGeneratingUrl ] = React . useState ( false )
80+ const [ isUpdatingRetention , setIsUpdatingRetention ] = React . useState ( false )
81+ const retentionDialogContentRef = React . useRef < HTMLDivElement > ( null )
7082 const resolvedObjectKey = React . useMemo ( ( ) => String ( object ?. Key ?? objectKey ?? "" ) , [ object ?. Key , objectKey ] )
7183 const objectPermissionContext = React . useMemo (
7284 ( ) => ( {
@@ -83,6 +95,10 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
8395 const canEditLegalHold = canCapability ( "objects.legalHold.edit" , objectPermissionContext )
8496 const canEditRetention = canCapability ( "objects.retention.edit" , objectPermissionContext )
8597 const canShareObject = canCapability ( "objects.share" , objectPermissionContext )
98+ const showRetentionAction = shouldShowObjectRetentionAction ( {
99+ canEditRetention,
100+ legalHoldEnabled : lockStatus ,
101+ } )
86102
87103 const formatDuration = ( seconds : number ) => {
88104 if ( seconds === 0 ) return ""
@@ -152,7 +168,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
152168 async ( key : string ) => {
153169 const info = await objectApi . getObjectInfo ( key )
154170 setObject ( info as Record < string , unknown > )
155- setLockStatus ( ( info as { ObjectLockLegalHoldStatus ?: string } ) ?. ObjectLockLegalHoldStatus === "ON" )
171+ setLockStatus ( isObjectLegalHoldEnabled ( info ) )
156172 setExpirationDays ( 0 )
157173 setExpirationHours ( 2 )
158174 setExpirationMinutes ( 0 )
@@ -192,22 +208,40 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
192208 [ objectApi ] ,
193209 )
194210
211+ const fetchLegalHold = React . useCallback (
212+ async ( key : string ) => {
213+ try {
214+ const response = await objectApi . getObjectLegalHold ( key )
215+ if ( response . Status ) {
216+ setLockStatus ( isObjectLegalHoldEnabled ( response ) )
217+ }
218+ } catch {
219+ // Keep the HeadObject fallback state when a server does not support the dedicated legal hold API.
220+ }
221+ } ,
222+ [ objectApi ] ,
223+ )
224+
195225 const loadObjectInfoRef = React . useRef ( loadObjectInfo )
196226 const fetchTagsRef = React . useRef ( fetchTags )
197227 const fetchRetentionRef = React . useRef ( fetchRetention )
228+ const fetchLegalHoldRef = React . useRef ( fetchLegalHold )
198229
199230 React . useEffect ( ( ) => {
200231 loadObjectInfoRef . current = loadObjectInfo
201232 fetchTagsRef . current = fetchTags
202233 fetchRetentionRef . current = fetchRetention
203- } , [ loadObjectInfo , fetchTags , fetchRetention ] )
234+ fetchLegalHoldRef . current = fetchLegalHold
235+ } , [ loadObjectInfo , fetchTags , fetchRetention , fetchLegalHold ] )
204236
205237 React . useEffect ( ( ) => {
206238 if ( open && objectKey ) {
207239 const key = objectKey
208240 loadObjectInfoRef
209241 . current ( key )
210- . then ( ( ) => Promise . all ( [ fetchTagsRef . current ( key ) , fetchRetentionRef . current ( key ) ] ) )
242+ . then ( ( ) =>
243+ Promise . all ( [ fetchTagsRef . current ( key ) , fetchRetentionRef . current ( key ) , fetchLegalHoldRef . current ( key ) ] ) ,
244+ )
211245 . catch ( ( ) => {
212246 message . error ( t ( "Failed to fetch object info" ) )
213247 setObject ( null )
@@ -292,8 +326,8 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
292326 }
293327 }
294328
295- const submitTagForm = async ( e : React . FormEvent ) => {
296- e . preventDefault ( )
329+ const submitTagForm = async ( e ? : React . FormEvent | React . MouseEvent < HTMLButtonElement > ) => {
330+ e ? .preventDefault ( )
297331 if ( ! canEditObjectTags || ! object ?. Key ) return
298332 if ( ! tagFormValue . Key || ! tagFormValue . Value ) {
299333 message . error ( t ( "Please fill in the correct format" ) )
@@ -332,40 +366,67 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
332366 }
333367 }
334368
335- const submitRetention = async ( e : React . FormEvent ) => {
336- e . preventDefault ( )
337- if ( ! canEditRetention || ! object ?. Key ) return
369+ const submitRetention = async ( ) => {
370+ if ( isUpdatingRetention ) return
371+ if ( ! resolvedObjectKey ) {
372+ message . error ( t ( "Failed to fetch object info" ) )
373+ return
374+ }
375+ if ( ! canEditRetention ) {
376+ message . error ( t ( "Update Failed" ) )
377+ return
378+ }
379+ if ( ! isObjectRetentionDateInFuture ( retainUntilDate ) ) {
380+ message . error ( t ( "The retain until date must be in the future" ) )
381+ return
382+ }
383+
384+ setIsUpdatingRetention ( true )
338385 try {
339- await objectApi . putObjectRetention ( object . Key as string , {
386+ await objectApi . putObjectRetention ( resolvedObjectKey , {
340387 Mode : retentionMode ,
341- RetainUntilDate : retainUntilDate || undefined ,
388+ RetainUntilDate : toObjectRetentionRequestValue ( retainUntilDate ) ,
342389 } )
343390 message . success ( t ( "Update Success" ) )
344391 setShowRetentionView ( false )
345- fetchRetention ( object . Key as string )
392+ fetchRetention ( resolvedObjectKey )
346393 } catch ( err ) {
347394 message . error ( ( err as Error ) ?. message ?? t ( "Update Failed" ) )
395+ } finally {
396+ setIsUpdatingRetention ( false )
348397 }
349398 }
350399
351400 const resetRetention = async ( ) => {
352- if ( ! canEditRetention || ! object ?. Key ) return
401+ if ( isUpdatingRetention ) return
402+ if ( ! resolvedObjectKey ) {
403+ message . error ( t ( "Failed to fetch object info" ) )
404+ return
405+ }
406+ if ( ! canEditRetention ) {
407+ message . error ( t ( "Update Failed" ) )
408+ return
409+ }
410+
411+ setIsUpdatingRetention ( true )
353412 try {
354- await objectApi . putObjectRetention ( object . Key as string , {
413+ await objectApi . putObjectRetention ( resolvedObjectKey , {
355414 Mode : "GOVERNANCE" ,
356415 } )
357416 message . success ( t ( "Update Success" ) )
358- fetchRetention ( object . Key as string )
417+ fetchRetention ( resolvedObjectKey )
359418 } catch ( err ) {
360419 message . error ( ( err as Error ) ?. message ?? t ( "Update Failed" ) )
420+ } finally {
421+ setIsUpdatingRetention ( false )
361422 }
362423 }
363424
364425 const lastModified = object ?. LastModified ? new Date ( object . LastModified as string | Date ) . toISOString ( ) : ""
365426
366427 return (
367428 < >
368- < Drawer open = { open } onOpenChange = { onOpenChange } direction = "right" >
429+ < Drawer open = { open } onOpenChange = { ( nextOpen ) => ! showRetentionView && onOpenChange ( nextOpen ) } direction = "right" >
369430 < DrawerContent className = "max-h-[95vh] overflow-y-auto overflow-x-hidden data-[vaul-drawer-direction=right]:w-[92vw] data-[vaul-drawer-direction=right]:sm:max-w-2xl" >
370431 < DrawerHeader >
371432 < DrawerTitle > { t ( "Object Details" ) } </ DrawerTitle >
@@ -397,8 +458,18 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
397458 { t ( "Versions" ) }
398459 </ Button >
399460 ) : null }
400- { lockStatus && canEditRetention ? (
401- < Button variant = "outline" size = "sm" onClick = { ( ) => setShowRetentionView ( true ) } >
461+ { showRetentionAction ? (
462+ < Button
463+ variant = "outline"
464+ size = "sm"
465+ onClick = { ( ) => {
466+ setMinRetentionDate ( getMinimumObjectRetentionDate ( ) )
467+ if ( ! retainUntilDate || ! isObjectRetentionDateInFuture ( retainUntilDate ) ) {
468+ setRetainUntilDate ( getDefaultObjectRetentionDate ( ) )
469+ }
470+ setShowRetentionView ( true )
471+ } }
472+ >
402473 < RiLockLine className = "size-4" />
403474 { t ( "Retention" ) }
404475 </ Button >
@@ -617,7 +688,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
617688 </ Field >
618689 </ div >
619690 < div className = "flex justify-end" >
620- < Button type = "submit " variant = "default" disabled = { ! canEditObjectTags } >
691+ < Button type = "button " variant = "default" onClick = { submitTagForm } disabled = { ! canEditObjectTags } >
621692 { t ( "Add" ) }
622693 </ Button >
623694 </ div >
@@ -628,55 +699,76 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
628699
629700 < Dialog open = { showRetentionView } onOpenChange = { setShowRetentionView } >
630701 < DialogContent className = "sm:max-w-lg" >
631- < DialogHeader >
632- < DialogTitle > { t ( "Retention" ) } </ DialogTitle >
633- </ DialogHeader >
634- < form className = "flex flex-col gap-3" onSubmit = { submitRetention } >
635- < Field >
636- < FieldLabel > { t ( "Retention Mode" ) } </ FieldLabel >
637- < FieldContent >
638- < RadioGroup
639- value = { retentionMode }
640- onValueChange = { ( v ) => setRetentionMode ( v as "COMPLIANCE" | "GOVERNANCE" ) }
641- className = "grid gap-2 sm:grid-cols-2"
702+ < div ref = { retentionDialogContentRef } className = "contents" >
703+ < DialogHeader >
704+ < DialogTitle > { t ( "Retention" ) } </ DialogTitle >
705+ </ DialogHeader >
706+ < div className = "flex flex-col gap-3" >
707+ < Field >
708+ < FieldLabel > { t ( "Retention Mode" ) } </ FieldLabel >
709+ < FieldContent >
710+ < RadioGroup
711+ value = { retentionMode }
712+ onValueChange = { ( v ) => setRetentionMode ( v as "COMPLIANCE" | "GOVERNANCE" ) }
713+ className = "grid gap-2 sm:grid-cols-2"
714+ >
715+ { [
716+ { label : t ( "COMPLIANCE" ) , value : "COMPLIANCE" } ,
717+ { label : t ( "GOVERNANCE" ) , value : "GOVERNANCE" } ,
718+ ] . map ( ( opt ) => (
719+ < label
720+ key = { opt . value }
721+ className = "flex items-start gap-3 rounded-md border border-border/50 p-3 cursor-pointer"
722+ >
723+ < RadioGroupItem value = { opt . value } className = "mt-0.5" />
724+ < span className = "text-sm font-medium" > { opt . label } </ span >
725+ </ label >
726+ ) ) }
727+ </ RadioGroup >
728+ </ FieldContent >
729+ </ Field >
730+ < Field >
731+ < FieldLabel > { t ( "Retention RetainUntilDate" ) } </ FieldLabel >
732+ < FieldContent >
733+ < DateTimePicker
734+ value = { retainUntilDate }
735+ onChange = { ( value ) => setRetainUntilDate ( value ?? "" ) }
736+ placeholder = { t ( "Retention RetainUntilDate" ) }
737+ min = { minRetentionDate }
738+ disabled = { ! canEditRetention || isUpdatingRetention }
739+ portalContainer = { retentionDialogContentRef }
740+ />
741+ </ FieldContent >
742+ </ Field >
743+ < div className = "flex justify-end gap-2" >
744+ < Button
745+ type = "button"
746+ variant = "secondary"
747+ onClick = { resetRetention }
748+ disabled = { ! canEditRetention || isUpdatingRetention }
642749 >
643- { [
644- { label : t ( "COMPLIANCE" ) , value : "COMPLIANCE" } ,
645- { label : t ( "GOVERNANCE" ) , value : "GOVERNANCE" } ,
646- ] . map ( ( opt ) => (
647- < label
648- key = { opt . value }
649- className = "flex items-start gap-3 rounded-md border border-border/50 p-3 cursor-pointer"
650- >
651- < RadioGroupItem value = { opt . value } className = "mt-0.5" />
652- < span className = "text-sm font-medium" > { opt . label } </ span >
653- </ label >
654- ) ) }
655- </ RadioGroup >
656- </ FieldContent >
657- </ Field >
658- < Field >
659- < FieldLabel > { t ( "Retention RetainUntilDate" ) } </ FieldLabel >
660- < FieldContent >
661- < Input
662- type = "datetime-local"
663- value = { retainUntilDate }
664- onChange = { ( e ) => setRetainUntilDate ( e . target . value ) }
665- />
666- </ FieldContent >
667- </ Field >
668- < div className = "flex justify-end gap-2" >
669- < Button type = "button" variant = "secondary" onClick = { resetRetention } disabled = { ! canEditRetention } >
670- { t ( "Reset" ) }
671- </ Button >
672- < Button type = "submit" variant = "default" disabled = { ! canEditRetention } >
673- { t ( "Confirm" ) }
674- </ Button >
675- < Button type = "button" variant = "outline" onClick = { ( ) => setShowRetentionView ( false ) } >
676- { t ( "Cancel" ) }
677- </ Button >
750+ { t ( "Reset" ) }
751+ </ Button >
752+ < Button
753+ type = "button"
754+ variant = "default"
755+ onClick = { ( ) => void submitRetention ( ) }
756+ disabled = { ! canEditRetention || isUpdatingRetention }
757+ >
758+ { isUpdatingRetention ? < Spinner className = "size-4" /> : null }
759+ { t ( "Confirm" ) }
760+ </ Button >
761+ < Button
762+ type = "button"
763+ variant = "outline"
764+ onClick = { ( ) => setShowRetentionView ( false ) }
765+ disabled = { isUpdatingRetention }
766+ >
767+ { t ( "Cancel" ) }
768+ </ Button >
769+ </ div >
678770 </ div >
679- </ form >
771+ </ div >
680772 </ DialogContent >
681773 </ Dialog >
682774
0 commit comments