Skip to content

Commit 07dddd1

Browse files
committed
fix: repair object retention controls
1 parent fd1d812 commit 07dddd1

25 files changed

Lines changed: 409 additions & 72 deletions

components/datetime-picker.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ interface DateTimePickerProps extends Omit<React.ComponentProps<typeof Button>,
5858
placeholder?: string
5959
min?: string
6060
max?: string
61+
portalContainer?: React.ComponentProps<typeof PopoverContent>["portalContainer"]
6162
}
6263

6364
export function DateTimePicker({
@@ -66,6 +67,7 @@ export function DateTimePicker({
6667
placeholder,
6768
min,
6869
max,
70+
portalContainer,
6971
className,
7072
disabled,
7173
id,
@@ -145,9 +147,10 @@ export function DateTimePicker({
145147
</Button>
146148
) : null}
147149
</div>
148-
<PopoverContent align="start" className="w-auto p-0" lang={htmlLocale}>
150+
<PopoverContent align="start" className="w-auto p-0" lang={htmlLocale} portalContainer={portalContainer}>
149151
<Calendar
150152
mode="single"
153+
required
151154
selected={selectedDate}
152155
onSelect={updateDate}
153156
disabled={disabledDays}
@@ -158,7 +161,10 @@ export function DateTimePicker({
158161
<div className="flex min-w-0 flex-1 items-center gap-2">
159162
<RiTimeLine className="size-4 text-muted-foreground" aria-hidden />
160163
<Input
164+
type="number"
161165
inputMode="numeric"
166+
min={0}
167+
max={23}
162168
value={hoursValue}
163169
onChange={updateHours}
164170
aria-label={t("Hours")}
@@ -167,7 +173,10 @@ export function DateTimePicker({
167173
/>
168174
<span className="text-muted-foreground">:</span>
169175
<Input
176+
type="number"
170177
inputMode="numeric"
178+
min={0}
179+
max={59}
171180
value={minutesValue}
172181
onChange={updateMinutes}
173182
aria-label={t("Minutes")}

components/object/info.tsx

Lines changed: 157 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,21 @@ import { Field, FieldContent, FieldLabel } from "@/components/ui/field"
1515
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
1616
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
1717
import { CopyInput } from "@/components/copy-input"
18+
import { DateTimePicker } from "@/components/datetime-picker"
1819
import { useObject } from "@/hooks/use-object"
1920
import { usePermissions } from "@/hooks/use-permissions"
2021
import { useMessage } from "@/lib/feedback/message"
2122
import { useDialog } from "@/lib/feedback/dialog"
2223
import { exportFile } from "@/lib/export-file"
2324
import { 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"
2433
import { ObjectVersions } from "@/components/object/versions"
2534
import { GetObjectCommand, HeadObjectCommand } from "@aws-sdk/client-s3"
2635
import { 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

Comments
 (0)