mirror of
https://github.com/rustfs/console.git
synced 2026-08-30 17:14:47 +08:00
fix: repair object retention controls
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": "معرف الدور",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 роли",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ò",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user