fix: repair object retention controls

This commit is contained in:
马登山
2026-05-14 10:08:38 +08:00
parent fd1d81265f
commit 07dddd1981
25 changed files with 409 additions and 72 deletions
+10 -1
View File
@@ -58,6 +58,7 @@ interface DateTimePickerProps extends Omit<React.ComponentProps<typeof Button>,
placeholder?: string
min?: string
max?: string
portalContainer?: React.ComponentProps<typeof PopoverContent>["portalContainer"]
}
export function DateTimePicker({
@@ -66,6 +67,7 @@ export function DateTimePicker({
placeholder,
min,
max,
portalContainer,
className,
disabled,
id,
@@ -145,9 +147,10 @@ export function DateTimePicker({
</Button>
) : null}
</div>
<PopoverContent align="start" className="w-auto p-0" lang={htmlLocale}>
<PopoverContent align="start" className="w-auto p-0" lang={htmlLocale} portalContainer={portalContainer}>
<Calendar
mode="single"
required
selected={selectedDate}
onSelect={updateDate}
disabled={disabledDays}
@@ -158,7 +161,10 @@ export function DateTimePicker({
<div className="flex min-w-0 flex-1 items-center gap-2">
<RiTimeLine className="size-4 text-muted-foreground" aria-hidden />
<Input
type="number"
inputMode="numeric"
min={0}
max={23}
value={hoursValue}
onChange={updateHours}
aria-label={t("Hours")}
@@ -167,7 +173,10 @@ export function DateTimePicker({
/>
<span className="text-muted-foreground">:</span>
<Input
type="number"
inputMode="numeric"
min={0}
max={59}
value={minutesValue}
onChange={updateMinutes}
aria-label={t("Minutes")}
+157 -65
View File
@@ -15,12 +15,21 @@ import { Field, FieldContent, FieldLabel } from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { CopyInput } from "@/components/copy-input"
import { DateTimePicker } from "@/components/datetime-picker"
import { useObject } from "@/hooks/use-object"
import { usePermissions } from "@/hooks/use-permissions"
import { useMessage } from "@/lib/feedback/message"
import { useDialog } from "@/lib/feedback/dialog"
import { exportFile } from "@/lib/export-file"
import { getContentType } from "@/lib/mime-types"
import {
getDefaultObjectRetentionDate,
getMinimumObjectRetentionDate,
isObjectLegalHoldEnabled,
isObjectRetentionDateInFuture,
shouldShowObjectRetentionAction,
toObjectRetentionRequestValue,
} from "@/lib/object-lock.js"
import { ObjectVersions } from "@/components/object/versions"
import { GetObjectCommand, HeadObjectCommand } from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
@@ -52,6 +61,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
const [retention, setRetention] = React.useState("")
const [retainUntilDate, setRetainUntilDate] = React.useState("")
const [retentionMode, setRetentionMode] = React.useState<"COMPLIANCE" | "GOVERNANCE">("GOVERNANCE")
const [minRetentionDate, setMinRetentionDate] = React.useState(() => getMinimumObjectRetentionDate())
const [signedUrl, setSignedUrl] = React.useState("")
const [showTagView, setShowTagView] = React.useState(false)
const [showRetentionView, setShowRetentionView] = React.useState(false)
@@ -67,6 +77,8 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
const [totalExpirationSeconds, setTotalExpirationSeconds] = React.useState(0)
const [isExpirationValid, setIsExpirationValid] = React.useState(false)
const [isGeneratingUrl, setIsGeneratingUrl] = React.useState(false)
const [isUpdatingRetention, setIsUpdatingRetention] = React.useState(false)
const retentionDialogContentRef = React.useRef<HTMLDivElement>(null)
const resolvedObjectKey = React.useMemo(() => String(object?.Key ?? objectKey ?? ""), [object?.Key, objectKey])
const objectPermissionContext = React.useMemo(
() => ({
@@ -83,6 +95,10 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
const canEditLegalHold = canCapability("objects.legalHold.edit", objectPermissionContext)
const canEditRetention = canCapability("objects.retention.edit", objectPermissionContext)
const canShareObject = canCapability("objects.share", objectPermissionContext)
const showRetentionAction = shouldShowObjectRetentionAction({
canEditRetention,
legalHoldEnabled: lockStatus,
})
const formatDuration = (seconds: number) => {
if (seconds === 0) return ""
@@ -152,7 +168,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
async (key: string) => {
const info = await objectApi.getObjectInfo(key)
setObject(info as Record<string, unknown>)
setLockStatus((info as { ObjectLockLegalHoldStatus?: string })?.ObjectLockLegalHoldStatus === "ON")
setLockStatus(isObjectLegalHoldEnabled(info))
setExpirationDays(0)
setExpirationHours(2)
setExpirationMinutes(0)
@@ -192,22 +208,40 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
[objectApi],
)
const fetchLegalHold = React.useCallback(
async (key: string) => {
try {
const response = await objectApi.getObjectLegalHold(key)
if (response.Status) {
setLockStatus(isObjectLegalHoldEnabled(response))
}
} catch {
// Keep the HeadObject fallback state when a server does not support the dedicated legal hold API.
}
},
[objectApi],
)
const loadObjectInfoRef = React.useRef(loadObjectInfo)
const fetchTagsRef = React.useRef(fetchTags)
const fetchRetentionRef = React.useRef(fetchRetention)
const fetchLegalHoldRef = React.useRef(fetchLegalHold)
React.useEffect(() => {
loadObjectInfoRef.current = loadObjectInfo
fetchTagsRef.current = fetchTags
fetchRetentionRef.current = fetchRetention
}, [loadObjectInfo, fetchTags, fetchRetention])
fetchLegalHoldRef.current = fetchLegalHold
}, [loadObjectInfo, fetchTags, fetchRetention, fetchLegalHold])
React.useEffect(() => {
if (open && objectKey) {
const key = objectKey
loadObjectInfoRef
.current(key)
.then(() => Promise.all([fetchTagsRef.current(key), fetchRetentionRef.current(key)]))
.then(() =>
Promise.all([fetchTagsRef.current(key), fetchRetentionRef.current(key), fetchLegalHoldRef.current(key)]),
)
.catch(() => {
message.error(t("Failed to fetch object info"))
setObject(null)
@@ -292,8 +326,8 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
}
}
const submitTagForm = async (e: React.FormEvent) => {
e.preventDefault()
const submitTagForm = async (e?: React.FormEvent | React.MouseEvent<HTMLButtonElement>) => {
e?.preventDefault()
if (!canEditObjectTags || !object?.Key) return
if (!tagFormValue.Key || !tagFormValue.Value) {
message.error(t("Please fill in the correct format"))
@@ -332,32 +366,59 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
}
}
const submitRetention = async (e: React.FormEvent) => {
e.preventDefault()
if (!canEditRetention || !object?.Key) return
const submitRetention = async () => {
if (isUpdatingRetention) return
if (!resolvedObjectKey) {
message.error(t("Failed to fetch object info"))
return
}
if (!canEditRetention) {
message.error(t("Update Failed"))
return
}
if (!isObjectRetentionDateInFuture(retainUntilDate)) {
message.error(t("The retain until date must be in the future"))
return
}
setIsUpdatingRetention(true)
try {
await objectApi.putObjectRetention(object.Key as string, {
await objectApi.putObjectRetention(resolvedObjectKey, {
Mode: retentionMode,
RetainUntilDate: retainUntilDate || undefined,
RetainUntilDate: toObjectRetentionRequestValue(retainUntilDate),
})
message.success(t("Update Success"))
setShowRetentionView(false)
fetchRetention(object.Key as string)
fetchRetention(resolvedObjectKey)
} catch (err) {
message.error((err as Error)?.message ?? t("Update Failed"))
} finally {
setIsUpdatingRetention(false)
}
}
const resetRetention = async () => {
if (!canEditRetention || !object?.Key) return
if (isUpdatingRetention) return
if (!resolvedObjectKey) {
message.error(t("Failed to fetch object info"))
return
}
if (!canEditRetention) {
message.error(t("Update Failed"))
return
}
setIsUpdatingRetention(true)
try {
await objectApi.putObjectRetention(object.Key as string, {
await objectApi.putObjectRetention(resolvedObjectKey, {
Mode: "GOVERNANCE",
})
message.success(t("Update Success"))
fetchRetention(object.Key as string)
fetchRetention(resolvedObjectKey)
} catch (err) {
message.error((err as Error)?.message ?? t("Update Failed"))
} finally {
setIsUpdatingRetention(false)
}
}
@@ -365,7 +426,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
return (
<>
<Drawer open={open} onOpenChange={onOpenChange} direction="right">
<Drawer open={open} onOpenChange={(nextOpen) => !showRetentionView && onOpenChange(nextOpen)} direction="right">
<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">
<DrawerHeader>
<DrawerTitle>{t("Object Details")}</DrawerTitle>
@@ -397,8 +458,18 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
{t("Versions")}
</Button>
) : null}
{lockStatus && canEditRetention ? (
<Button variant="outline" size="sm" onClick={() => setShowRetentionView(true)}>
{showRetentionAction ? (
<Button
variant="outline"
size="sm"
onClick={() => {
setMinRetentionDate(getMinimumObjectRetentionDate())
if (!retainUntilDate || !isObjectRetentionDateInFuture(retainUntilDate)) {
setRetainUntilDate(getDefaultObjectRetentionDate())
}
setShowRetentionView(true)
}}
>
<RiLockLine className="size-4" />
{t("Retention")}
</Button>
@@ -617,7 +688,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
</Field>
</div>
<div className="flex justify-end">
<Button type="submit" variant="default" disabled={!canEditObjectTags}>
<Button type="button" variant="default" onClick={submitTagForm} disabled={!canEditObjectTags}>
{t("Add")}
</Button>
</div>
@@ -628,55 +699,76 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
<Dialog open={showRetentionView} onOpenChange={setShowRetentionView}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("Retention")}</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-3" onSubmit={submitRetention}>
<Field>
<FieldLabel>{t("Retention Mode")}</FieldLabel>
<FieldContent>
<RadioGroup
value={retentionMode}
onValueChange={(v) => setRetentionMode(v as "COMPLIANCE" | "GOVERNANCE")}
className="grid gap-2 sm:grid-cols-2"
<div ref={retentionDialogContentRef} className="contents">
<DialogHeader>
<DialogTitle>{t("Retention")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<Field>
<FieldLabel>{t("Retention Mode")}</FieldLabel>
<FieldContent>
<RadioGroup
value={retentionMode}
onValueChange={(v) => setRetentionMode(v as "COMPLIANCE" | "GOVERNANCE")}
className="grid gap-2 sm:grid-cols-2"
>
{[
{ label: t("COMPLIANCE"), value: "COMPLIANCE" },
{ label: t("GOVERNANCE"), value: "GOVERNANCE" },
].map((opt) => (
<label
key={opt.value}
className="flex items-start gap-3 rounded-md border border-border/50 p-3 cursor-pointer"
>
<RadioGroupItem value={opt.value} className="mt-0.5" />
<span className="text-sm font-medium">{opt.label}</span>
</label>
))}
</RadioGroup>
</FieldContent>
</Field>
<Field>
<FieldLabel>{t("Retention RetainUntilDate")}</FieldLabel>
<FieldContent>
<DateTimePicker
value={retainUntilDate}
onChange={(value) => setRetainUntilDate(value ?? "")}
placeholder={t("Retention RetainUntilDate")}
min={minRetentionDate}
disabled={!canEditRetention || isUpdatingRetention}
portalContainer={retentionDialogContentRef}
/>
</FieldContent>
</Field>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="secondary"
onClick={resetRetention}
disabled={!canEditRetention || isUpdatingRetention}
>
{[
{ label: t("COMPLIANCE"), value: "COMPLIANCE" },
{ label: t("GOVERNANCE"), value: "GOVERNANCE" },
].map((opt) => (
<label
key={opt.value}
className="flex items-start gap-3 rounded-md border border-border/50 p-3 cursor-pointer"
>
<RadioGroupItem value={opt.value} className="mt-0.5" />
<span className="text-sm font-medium">{opt.label}</span>
</label>
))}
</RadioGroup>
</FieldContent>
</Field>
<Field>
<FieldLabel>{t("Retention RetainUntilDate")}</FieldLabel>
<FieldContent>
<Input
type="datetime-local"
value={retainUntilDate}
onChange={(e) => setRetainUntilDate(e.target.value)}
/>
</FieldContent>
</Field>
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={resetRetention} disabled={!canEditRetention}>
{t("Reset")}
</Button>
<Button type="submit" variant="default" disabled={!canEditRetention}>
{t("Confirm")}
</Button>
<Button type="button" variant="outline" onClick={() => setShowRetentionView(false)}>
{t("Cancel")}
</Button>
{t("Reset")}
</Button>
<Button
type="button"
variant="default"
onClick={() => void submitRetention()}
disabled={!canEditRetention || isUpdatingRetention}
>
{isUpdatingRetention ? <Spinner className="size-4" /> : null}
{t("Confirm")}
</Button>
<Button
type="button"
variant="outline"
onClick={() => setShowRetentionView(false)}
disabled={isUpdatingRetention}
>
{t("Cancel")}
</Button>
</div>
</div>
</form>
</div>
</DialogContent>
</Dialog>
+5 -2
View File
@@ -17,13 +17,16 @@ function PopoverContent({
className,
align = "center",
alignOffset = 0,
portalContainer,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<PopoverPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
Pick<PopoverPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset"> & {
portalContainer?: PopoverPrimitive.Portal.Props["container"]
}) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Portal container={portalContainer}>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
+18
View File
@@ -3,6 +3,7 @@
import { useCallback } from "react"
import {
DeleteObjectCommand,
GetObjectLegalHoldCommand,
GetObjectRetentionCommand,
GetObjectTaggingCommand,
GetObjectCommand,
@@ -196,6 +197,22 @@ export function useObject(bucket: string) {
[client, bucket],
)
const getObjectLegalHold = useCallback(
async (key: string): Promise<{ Status: string }> => {
try {
const response = await client.send(new GetObjectLegalHoldCommand({ Bucket: bucket, Key: key }))
return { Status: response?.LegalHold?.Status ?? "" }
} catch (err) {
const msg = (err as Error)?.message ?? ""
if (msg.includes("Deserialization error")) {
return { Status: "" }
}
throw err
}
},
[client, bucket],
)
const putObjectRetention = useCallback(
async (
key: string,
@@ -260,6 +277,7 @@ export function useObject(bucket: string) {
getObjectInfo,
getObjectTags,
putObjectTags,
getObjectLegalHold,
getObjectRetention,
putObjectRetention,
setLegalHold,
+1
View File
@@ -931,6 +931,7 @@
"Retention RetainUntilDate": "تاريخ الاحتجاز حتى",
"Retention Save Failed": "فشل حفظ الاحتجاز",
"Retention Unit": "وحدة الاحتجاز",
"The retain until date must be in the future": "The retain until date must be in the future",
"Retry Attempts": "محاولات إعادة المحاولة",
"Review before decommission": "مراجعة قبل الإخراج من الخدمة",
"Role ID": "معرف الدور",
+1
View File
@@ -931,6 +931,7 @@
"Retention RetainUntilDate": "Aufbewahrung bis Datum",
"Retention Save Failed": "Aufbewahrung speichern fehlgeschlagen",
"Retention Unit": "Aufbewahrungseinheit",
"The retain until date must be in the future": "The retain until date must be in the future",
"Retry Attempts": "Wiederholungsversuche",
"Review before decommission": "Vor der Stilllegung prüfen",
"Role ID": "Rollen-ID",
+1
View File
@@ -931,6 +931,7 @@
"Retention RetainUntilDate": "Retention RetainUntilDate",
"Retention Save Failed": "Retention Save Failed",
"Retention Unit": "Retention Unit",
"The retain until date must be in the future": "The retain until date must be in the future",
"Retry Attempts": "Retry Attempts",
"Review before decommission": "Review before decommission",
"Role ID": "Role ID",
+1
View File
@@ -931,6 +931,7 @@
"Retention RetainUntilDate": "Retención Hasta Fecha",
"Retention Save Failed": "Error al Guardar Retención",
"Retention Unit": "Unidad de Retención",
"The retain until date must be in the future": "The retain until date must be in the future",
"Retry Attempts": "Intentos de Reintento",
"Review before decommission": "Revisar antes de retirar",
"Role ID": "ID de Rol",
+1
View File
@@ -931,6 +931,7 @@
"Retention RetainUntilDate": "Date de conservation de la rétention",
"Retention Save Failed": "Échec de l'enregistrement de la rétention",
"Retention Unit": "Unité de rétention",
"The retain until date must be in the future": "The retain until date must be in the future",
"Retry Attempts": "Tentatives de reprise",
"Review before decommission": "Vérifier avant le retrait",
"Role ID": "ID de rôle",
+1
View File
@@ -931,6 +931,7 @@
"Retention RetainUntilDate": "Retention RetainUntilDate",
"Retention Save Failed": "Gagal Menyimpan Retensi",
"Retention Unit": "Unit Retensi",
"The retain until date must be in the future": "The retain until date must be in the future",
"Retry Attempts": "Percobaan Ulang",
"Review before decommission": "Tinjau sebelum pensiun",
"Role ID": "Role ID",
+1
View File
@@ -931,6 +931,7 @@
"Retention RetainUntilDate": "Conservazione fino a data",
"Retention Save Failed": "Salvataggio conservazione non riuscito",
"Retention Unit": "Unità conservazione",
"The retain until date must be in the future": "The retain until date must be in the future",
"Retry Attempts": "Tentativi di ripetizione",
"Review before decommission": "Verifica prima della dismissione",
"Role ID": "ID ruolo",
+1
View File
@@ -931,6 +931,7 @@
"Retention RetainUntilDate": "保持期限日",
"Retention Save Failed": "保持の保存に失敗しました",
"Retention Unit": "保持単位",
"The retain until date must be in the future": "The retain until date must be in the future",
"Retry Attempts": "再試行回数",
"Review before decommission": "廃止前の確認",
"Role ID": "ロールID",
+1
View File
@@ -931,6 +931,7 @@
"Retention RetainUntilDate": "보관 유지 날짜",
"Retention Save Failed": "보관 저장 실패",
"Retention Unit": "보관 단위",
"The retain until date must be in the future": "The retain until date must be in the future",
"Retry Attempts": "재시도 횟수",
"Review before decommission": "폐기 전 검토",
"Role ID": "역할 ID",
+1
View File
@@ -931,6 +931,7 @@
"Retention RetainUntilDate": "Retenção RetainUntilDate",
"Retention Save Failed": "Falha ao Salvar Retenção",
"Retention Unit": "Unidade de Retenção",
"The retain until date must be in the future": "The retain until date must be in the future",
"Retry Attempts": "Tentativas de Repetição",
"Review before decommission": "Revisar antes do descomissionamento",
"Role ID": "ID de Função",
+1
View File
@@ -931,6 +931,7 @@
"Retention RetainUntilDate": "Удержание до даты",
"Retention Save Failed": "Ошибка сохранения удержания",
"Retention Unit": "Единица удержания",
"The retain until date must be in the future": "The retain until date must be in the future",
"Retry Attempts": "Попытки повтора",
"Review before decommission": "Проверка перед выводом из эксплуатации",
"Role ID": "ID роли",
+1
View File
@@ -931,6 +931,7 @@
"Retention RetainUntilDate": "Saklama SonTariheKadar",
"Retention Save Failed": "Saklama Kaydetme Başarısız",
"Retention Unit": "Saklama Birimi",
"The retain until date must be in the future": "The retain until date must be in the future",
"Retry Attempts": "Denemeleri Yeniden Dene",
"Review before decommission": "Devreden çıkarmadan önce incele",
"Role ID": "Rol Kimliği",
+1
View File
@@ -931,6 +931,7 @@
"Retention RetainUntilDate": "Lưu giữ cho đến ngày",
"Retention Save Failed": "Lưu thiết lập lưu giữ thất bại",
"Retention Unit": "Đơn vị lưu giữ",
"The retain until date must be in the future": "The retain until date must be in the future",
"Retry Attempts": "Số lần thử lại",
"Review before decommission": "Xem lại trước khi ngừng sử dụng",
"Role ID": "Mã vai trò",
+1
View File
@@ -931,6 +931,7 @@
"Retention RetainUntilDate": "保留至日期",
"Retention Save Failed": "保留保存失败",
"Retention Unit": "保留单位",
"The retain until date must be in the future": "保留截止时间必须是未来时间",
"Retry Attempts": "重试次数",
"Review before decommission": "退役前确认",
"Role ID": "角色 ID",
+4
View File
@@ -76,6 +76,10 @@ const IMPLIED_SCOPES: Record<string, string[]> = {
"s3:GetObjectTagging",
"s3:PutObjectTagging",
"s3:DeleteObjectTagging",
"s3:GetObjectLegalHold",
"s3:PutObjectLegalHold",
"s3:GetObjectRetention",
"s3:PutObjectRetention",
"s3:GetBucketTagging",
"s3:PutBucketTagging",
"s3:GetBucketPolicy",
+4 -4
View File
@@ -60,11 +60,11 @@ export function applyDateTimeBounds(value, min, max) {
const current = dayjs(value)
if (!current.isValid()) return null
const minDate = dayjs(min)
if (minDate.isValid() && current.isBefore(minDate)) return minDate.toISOString()
const minDate = min ? dayjs(min) : null
if (minDate?.isValid() && current.isBefore(minDate)) return minDate.toISOString()
const maxDate = dayjs(max)
if (maxDate.isValid() && current.isAfter(maxDate)) return maxDate.toISOString()
const maxDate = max ? dayjs(max) : null
if (maxDate?.isValid() && current.isAfter(maxDate)) return maxDate.toISOString()
return current.toISOString()
}
+35
View File
@@ -0,0 +1,35 @@
import dayjs from "dayjs"
export function isObjectLegalHoldEnabled(value) {
if (typeof value === "string") return value === "ON"
return value?.Status === "ON" || value?.LegalHold?.Status === "ON" || value?.ObjectLockLegalHoldStatus === "ON"
}
export function toObjectRetentionInputValue(value) {
return value && dayjs(value).isValid() ? dayjs(value).format("YYYY-MM-DDTHH:mm") : ""
}
export function toObjectRetentionRequestValue(value) {
if (!value) return undefined
const date = dayjs(value)
return date.isValid() ? date.toISOString() : undefined
}
export function getDefaultObjectRetentionDate(now = new Date()) {
return dayjs(now).add(1, "day").second(0).millisecond(0).toISOString()
}
export function getMinimumObjectRetentionDate(now = new Date()) {
return dayjs(now).add(1, "minute").second(0).millisecond(0).toISOString()
}
export function isObjectRetentionDateInFuture(value, now = new Date()) {
const date = dayjs(value)
return date.isValid() && date.isAfter(dayjs(now))
}
export function shouldShowObjectRetentionAction({ canEditRetention, legalHoldEnabled }) {
return Boolean(canEditRetention && legalHoldEnabled)
}
+23
View File
@@ -56,6 +56,13 @@ test("clamps ISO values to min and max bounds", () => {
assert.equal(applyDateTimeBounds("not-a-date", min, max), null)
})
test("keeps future-day times when the minimum is near the current time", () => {
const min = "2026-05-14T12:01:00.000Z"
const tomorrowEarly = "2026-05-15T01:00:00.000Z"
assert.equal(applyDateTimeBounds(tomorrowEarly, min), tomorrowEarly)
})
test("returns localized display text", () => {
const value = "2026-05-08T09:30:00.000Z"
@@ -70,3 +77,19 @@ test("DateTimePicker does not render native date or time picker inputs inside th
assert.equal(source.includes('type="date"'), false)
assert.equal(source.includes('type="time"'), false)
})
test("DateTimePicker can render its popover inside an existing dialog", () => {
const pickerSource = fs.readFileSync("components/datetime-picker.tsx", "utf8")
const popoverSource = fs.readFileSync("components/ui/popover.tsx", "utf8")
assert.equal(popoverSource.includes("portalContainer?:"), true)
assert.equal(popoverSource.includes("<PopoverPrimitive.Portal container={portalContainer}>"), true)
assert.equal(pickerSource.includes("portalContainer={portalContainer}"), true)
})
test("DateTimePicker keeps the calendar date selected when clicked again", () => {
const source = fs.readFileSync("components/datetime-picker.tsx", "utf8")
assert.equal(source.includes('mode="single"'), true)
assert.equal(source.includes("required"), true)
})
+23
View File
@@ -0,0 +1,23 @@
import test from "node:test"
import assert from "node:assert/strict"
import fs from "node:fs"
test("object info loads legal hold with the dedicated object lock API", () => {
const hookSource = fs.readFileSync("hooks/use-object.ts", "utf8")
const componentSource = fs.readFileSync("components/object/info.tsx", "utf8")
assert.equal(hookSource.includes("GetObjectLegalHoldCommand"), true)
assert.equal(componentSource.includes("objectApi.getObjectLegalHold(key)"), true)
})
test("object info retention confirm uses an explicit click handler", () => {
const source = fs.readFileSync("components/object/info.tsx", "utf8")
assert.equal(source.includes('type="datetime-local"'), false)
assert.equal(source.includes("legalHoldEnabled: lockStatus"), true)
assert.equal(source.includes("<DateTimePicker"), true)
assert.equal(source.includes("portalContainer={retentionDialogContentRef}"), true)
assert.equal(source.includes("!showRetentionView && onOpenChange(nextOpen)"), true)
assert.match(source, /onClick=\{\(\) => void submitRetention\(\)\}/)
assert.match(source, /objectApi\.putObjectRetention\(resolvedObjectKey/)
})
+24
View File
@@ -0,0 +1,24 @@
import test from "node:test"
import assert from "node:assert/strict"
import { CONSOLE_SCOPES } from "../../lib/console-permissions"
import type { ConsolePolicy } from "../../lib/console-policy-parser"
import { hasConsoleCapability } from "../../lib/permission-capabilities"
const browserPolicy: ConsolePolicy = {
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Action: [CONSOLE_SCOPES.VIEW_BROWSER],
Resource: ["console"],
},
],
}
test("browser console scope allows editing object lock controls", () => {
const context = { bucket: "locked-bucket", objectKey: "folder/object.txt" }
assert.equal(hasConsoleCapability(browserPolicy, "objects.legalHold.edit", context), true)
assert.equal(hasConsoleCapability(browserPolicy, "objects.retention.edit", context), true)
})
+92
View File
@@ -0,0 +1,92 @@
import test from "node:test"
import assert from "node:assert/strict"
import {
getDefaultObjectRetentionDate,
getMinimumObjectRetentionDate,
isObjectLegalHoldEnabled,
isObjectRetentionDateInFuture,
shouldShowObjectRetentionAction,
toObjectRetentionInputValue,
toObjectRetentionRequestValue,
} from "../../lib/object-lock.js"
test("isObjectLegalHoldEnabled reads legal hold from GetObjectLegalHold response", () => {
assert.equal(isObjectLegalHoldEnabled({ Status: "ON" }), true)
assert.equal(isObjectLegalHoldEnabled({ Status: "OFF" }), false)
assert.equal(isObjectLegalHoldEnabled({ LegalHold: { Status: "ON" } }), true)
assert.equal(isObjectLegalHoldEnabled({ LegalHold: { Status: "OFF" } }), false)
})
test("isObjectLegalHoldEnabled keeps HeadObject legal hold status as a fallback", () => {
assert.equal(isObjectLegalHoldEnabled({ ObjectLockLegalHoldStatus: "ON" }), true)
assert.equal(isObjectLegalHoldEnabled({ ObjectLockLegalHoldStatus: "OFF" }), false)
})
test("toObjectRetentionInputValue formats ISO retention dates for datetime-local inputs", () => {
const inputValue = toObjectRetentionInputValue("2026-05-08T09:30:00.000Z")
assert.equal(inputValue.length, 16)
assert.match(inputValue, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/)
})
test("toObjectRetentionRequestValue converts datetime-local input values to ISO strings", () => {
const requestValue = toObjectRetentionRequestValue("2026-05-08T14:35")
assert.equal(typeof requestValue, "string")
assert.equal(toObjectRetentionInputValue(requestValue), "2026-05-08T14:35")
})
test("toObjectRetentionRequestValue omits empty or invalid dates", () => {
assert.equal(toObjectRetentionRequestValue(""), undefined)
assert.equal(toObjectRetentionRequestValue("not-a-date"), undefined)
})
test("getDefaultObjectRetentionDate chooses a future retention date", () => {
const now = new Date("2026-05-14T12:00:00.000Z")
const defaultDate = getDefaultObjectRetentionDate(now)
assert.equal(isObjectRetentionDateInFuture(defaultDate, now), true)
})
test("getMinimumObjectRetentionDate keeps the picker bound close to now", () => {
const now = new Date("2026-05-14T12:00:00.000Z")
const minimumDate = getMinimumObjectRetentionDate(now)
const tomorrowMorning = "2026-05-15T01:00:00.000Z"
assert.equal(isObjectRetentionDateInFuture(minimumDate, now), true)
assert.equal(isObjectRetentionDateInFuture(tomorrowMorning, minimumDate), true)
})
test("isObjectRetentionDateInFuture rejects empty, invalid, and past dates", () => {
const now = new Date("2026-05-14T12:00:00.000Z")
assert.equal(isObjectRetentionDateInFuture("", now), false)
assert.equal(isObjectRetentionDateInFuture("not-a-date", now), false)
assert.equal(isObjectRetentionDateInFuture("2026-05-14T11:59:00.000Z", now), false)
assert.equal(isObjectRetentionDateInFuture("2026-05-14T12:01:00.000Z", now), true)
})
test("shouldShowObjectRetentionAction requires retention permission and legal hold enabled", () => {
assert.equal(
shouldShowObjectRetentionAction({
canEditRetention: true,
legalHoldEnabled: true,
}),
true,
)
assert.equal(
shouldShowObjectRetentionAction({
canEditRetention: true,
legalHoldEnabled: false,
}),
false,
)
assert.equal(
shouldShowObjectRetentionAction({
canEditRetention: false,
legalHoldEnabled: true,
}),
false,
)
})