mirror of
https://github.com/rustfs/console.git
synced 2026-10-30 19:33:40 +08:00
fix: restore user service account management (#106)
This commit is contained in:
@@ -264,7 +264,13 @@ export default function AccessKeysPage() {
|
||||
onSuccess={listUserAccounts}
|
||||
/>
|
||||
|
||||
<UserNotice open={noticeOpen} onOpenChange={setNoticeOpen} data={noticeData} onClose={handleNoticeClose} />
|
||||
<UserNotice
|
||||
open={noticeOpen}
|
||||
onOpenChange={setNoticeOpen}
|
||||
data={noticeData}
|
||||
onClose={handleNoticeClose}
|
||||
title={t("New access key has been created")}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,9 +12,11 @@ import { usePermissions } from "@/hooks/use-permissions"
|
||||
import { usePolicies } from "@/hooks/use-policies"
|
||||
import { useGroups } from "@/hooks/use-groups"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import { getAvailableUserEditTabs } from "@/lib/user-edit-tabs"
|
||||
import { UserEditSecretKey } from "./edit/secret-key"
|
||||
import { UserEditGroups } from "./edit/groups"
|
||||
import { UserEditPolicies } from "./edit/policies"
|
||||
import { UserEditAccessKeys } from "./edit/access-keys"
|
||||
|
||||
interface UserRow {
|
||||
accessKey: string
|
||||
@@ -49,6 +51,8 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor
|
||||
const canEditAccount = canCapability("users.edit")
|
||||
const canAssignGroups = canCapability("users.assignGroups")
|
||||
const canEditPolicies = canCapability("users.policy.edit")
|
||||
const canManageAccessKeys =
|
||||
canCapability("accessKeys.create") || canCapability("accessKeys.edit") || canCapability("accessKeys.delete")
|
||||
|
||||
const [user, setUser] = React.useState<{
|
||||
accessKey: string
|
||||
@@ -72,12 +76,13 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor
|
||||
|
||||
const statusBoolean = user.status === "enabled"
|
||||
const availableTabs = React.useMemo(() => {
|
||||
const tabs: string[] = []
|
||||
if (canEditAccount) tabs.push("account")
|
||||
if (canAssignGroups) tabs.push("groups")
|
||||
if (canEditPolicies) tabs.push("policy")
|
||||
return tabs
|
||||
}, [canAssignGroups, canEditAccount, canEditPolicies])
|
||||
return getAvailableUserEditTabs({
|
||||
canEditAccount,
|
||||
canAssignGroups,
|
||||
canEditPolicies,
|
||||
canManageAccessKeys,
|
||||
})
|
||||
}, [canAssignGroups, canEditAccount, canEditPolicies, canManageAccessKeys])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !row?.accessKey) return
|
||||
@@ -223,7 +228,7 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={closeModal}>
|
||||
<DialogContent
|
||||
className="sm:max-w-lg"
|
||||
className="sm:max-w-5xl"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
@@ -237,6 +242,7 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor
|
||||
{canEditAccount ? <TabsTrigger value="account">{t("Account")}</TabsTrigger> : null}
|
||||
{canAssignGroups ? <TabsTrigger value="groups">{t("Groups")}</TabsTrigger> : null}
|
||||
{canEditPolicies ? <TabsTrigger value="policy">{t("Policies")}</TabsTrigger> : null}
|
||||
{canManageAccessKeys ? <TabsTrigger value="access-keys">{t("Access Keys")}</TabsTrigger> : null}
|
||||
</TabsList>
|
||||
|
||||
{canEditAccount ? (
|
||||
@@ -279,6 +285,12 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor
|
||||
/>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{canManageAccessKeys ? (
|
||||
<TabsContent value="access-keys" className="mt-0">
|
||||
<UserEditAccessKeys userName={user.accessKey} />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,736 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import dayjs from "dayjs"
|
||||
import { RiAddLine, RiDeleteBin5Line, RiEdit2Line } from "@remixicon/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ui/field"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { SearchInput } from "@/components/search-input"
|
||||
import { DateTimePicker } from "@/components/datetime-picker"
|
||||
import { DataTable } from "@/components/data-table/data-table"
|
||||
import { UserNotice, type CredentialsData } from "@/components/user/notice"
|
||||
import { useDataTable } from "@/hooks/use-data-table"
|
||||
import { useUsers } from "@/hooks/use-users"
|
||||
import { useAccessKeys } from "@/hooks/use-access-keys"
|
||||
import { usePermissions } from "@/hooks/use-permissions"
|
||||
import { usePolicies } from "@/hooks/use-policies"
|
||||
import { useDialog } from "@/lib/feedback/dialog"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import { makeRandomString } from "@/lib/functions"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
|
||||
interface UserEditAccessKeysProps {
|
||||
userName: string
|
||||
}
|
||||
|
||||
interface AccessKeyRow {
|
||||
accessKey: string
|
||||
expiration?: string | null
|
||||
name?: string
|
||||
description?: string
|
||||
accountStatus?: string
|
||||
impliedPolicy?: boolean
|
||||
policy?: unknown
|
||||
}
|
||||
|
||||
interface AccessKeyDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
userName: string
|
||||
onSuccess: () => void
|
||||
onNotice?: (data: CredentialsData) => void
|
||||
}
|
||||
|
||||
interface EditDialogProps extends AccessKeyDialogProps {
|
||||
row: AccessKeyRow | null
|
||||
}
|
||||
|
||||
function formatExpiration(value?: string | null) {
|
||||
if (!value || value === "9999-01-01T00:00:00Z") return "-"
|
||||
const date = dayjs(value)
|
||||
if (!date.isValid()) return "-"
|
||||
return date.format("YYYY-MM-DD HH:mm")
|
||||
}
|
||||
|
||||
function formatPolicy(value: unknown) {
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value) as Record<string, unknown>, null, 2)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(value ?? {}, null, 2)
|
||||
}
|
||||
|
||||
function UserAccessKeysNewDialog({ open, onOpenChange, userName, onSuccess, onNotice }: AccessKeyDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const { createAUserServiceAccount } = useUsers()
|
||||
const { getPolicyByUserName } = usePolicies()
|
||||
|
||||
const [accessKey, setAccessKey] = React.useState("")
|
||||
const [secretKey, setSecretKey] = React.useState("")
|
||||
const [name, setName] = React.useState("")
|
||||
const [description, setDescription] = React.useState("")
|
||||
const [expiry, setExpiry] = React.useState<string | null>(null)
|
||||
const [policy, setPolicy] = React.useState("{}")
|
||||
const [parentPolicy, setParentPolicy] = React.useState("{}")
|
||||
const [impliedPolicy, setImpliedPolicy] = React.useState(true)
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
const [errors, setErrors] = React.useState({
|
||||
accessKey: "",
|
||||
secretKey: "",
|
||||
name: "",
|
||||
})
|
||||
|
||||
const minExpiry = React.useMemo(() => new Date().toISOString(), [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
try {
|
||||
setAccessKey(makeRandomString(20))
|
||||
setSecretKey(makeRandomString(40))
|
||||
} catch {
|
||||
setAccessKey("")
|
||||
setSecretKey("")
|
||||
message.error(t("Failed to generate secure random keys"))
|
||||
}
|
||||
|
||||
setName("")
|
||||
setDescription("")
|
||||
setExpiry(null)
|
||||
setImpliedPolicy(true)
|
||||
setErrors({ accessKey: "", secretKey: "", name: "" })
|
||||
|
||||
getPolicyByUserName(userName)
|
||||
.then((result) => {
|
||||
const value = JSON.stringify(result ?? {}, null, 2)
|
||||
setParentPolicy(value)
|
||||
setPolicy(value)
|
||||
})
|
||||
.catch(() => {
|
||||
setParentPolicy("{}")
|
||||
setPolicy("{}")
|
||||
})
|
||||
}, [getPolicyByUserName, message, open, t, userName])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (impliedPolicy) {
|
||||
setPolicy(parentPolicy)
|
||||
}
|
||||
}, [impliedPolicy, parentPolicy])
|
||||
|
||||
const closeModal = () => {
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
const nextErrors = { accessKey: "", secretKey: "", name: "" }
|
||||
|
||||
if (!accessKey) {
|
||||
nextErrors.accessKey = t("Please enter Access Key")
|
||||
} else if (accessKey.length < 3 || accessKey.length > 20) {
|
||||
nextErrors.accessKey = t("Access Key length must be between 3 and 20 characters")
|
||||
}
|
||||
|
||||
if (!secretKey) {
|
||||
nextErrors.secretKey = t("Please enter Secret Key")
|
||||
} else if (secretKey.length < 8 || secretKey.length > 40) {
|
||||
nextErrors.secretKey = t("Secret Key length must be between 8 and 40 characters")
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
nextErrors.name = t("Please enter name")
|
||||
}
|
||||
|
||||
setErrors(nextErrors)
|
||||
return !nextErrors.accessKey && !nextErrors.secretKey && !nextErrors.name
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!validate()) {
|
||||
message.error(t("Please fill in the correct format"))
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
let customPolicy: string | null = null
|
||||
if (!impliedPolicy) {
|
||||
try {
|
||||
customPolicy = JSON.stringify(JSON.parse(policy || "{}") as Record<string, unknown>)
|
||||
} catch {
|
||||
message.error(t("Policy format invalid"))
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
accessKey,
|
||||
secretKey,
|
||||
name,
|
||||
description,
|
||||
policy: customPolicy,
|
||||
expiration: expiry ? expiry : "9999-01-01T00:00:00.000Z",
|
||||
}
|
||||
|
||||
const result = (await createAUserServiceAccount(userName, payload)) as CredentialsData
|
||||
message.success(t("Added successfully"))
|
||||
onNotice?.(result)
|
||||
closeModal()
|
||||
onSuccess()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error(t("Add failed"))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={closeModal}>
|
||||
<DialogContent
|
||||
className={cn("sm:max-w-xl", !impliedPolicy && "sm:max-w-6xl")}
|
||||
onPointerDownOutside={(event) => event.preventDefault()}
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("Create Key")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex max-h-[80vh] flex-col gap-4 overflow-auto px-2 -mx-2 lg:flex-row">
|
||||
<div
|
||||
className={cn(
|
||||
impliedPolicy ? "flex flex-1 flex-col gap-4" : "flex w-full flex-col gap-4 lg:w-72 lg:shrink-0",
|
||||
)}
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-user-access-key">{t("Access Key")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="create-user-access-key"
|
||||
value={accessKey}
|
||||
onChange={(event) => setAccessKey(event.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FieldContent>
|
||||
{errors.accessKey ? (
|
||||
<FieldDescription className="text-destructive">{errors.accessKey}</FieldDescription>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-user-secret-key">{t("Secret Key")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="create-user-secret-key"
|
||||
type="password"
|
||||
value={secretKey}
|
||||
onChange={(event) => setSecretKey(event.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FieldContent>
|
||||
{errors.secretKey ? (
|
||||
<FieldDescription className="text-destructive">{errors.secretKey}</FieldDescription>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-user-expiry">
|
||||
{t("Expiry")}({t("empty is indicates permanent validity")})
|
||||
</FieldLabel>
|
||||
<FieldContent>
|
||||
<DateTimePicker
|
||||
id="create-user-expiry"
|
||||
value={expiry}
|
||||
onChange={setExpiry}
|
||||
min={minExpiry}
|
||||
placeholder={t("Please select expiry date")}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-user-key-name">{t("Name")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="create-user-key-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FieldContent>
|
||||
{errors.name ? <FieldDescription className="text-destructive">{errors.name}</FieldDescription> : null}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="create-user-key-description">{t("Description")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="create-user-key-description"
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field orientation="responsive" className="items-start gap-3 rounded-md border p-3">
|
||||
<FieldLabel className="text-sm font-medium">{t("Use main account policy")}</FieldLabel>
|
||||
<FieldContent className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("Automatically inherit the main account policy when enabled.")}
|
||||
</p>
|
||||
<Switch checked={impliedPolicy} onCheckedChange={setImpliedPolicy} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{!impliedPolicy ? (
|
||||
<div className="flex-1 max-h-[60vh] overflow-auto">
|
||||
<Field className="h-full">
|
||||
<FieldLabel>{t("Current user policy")}</FieldLabel>
|
||||
<FieldContent className="h-full">
|
||||
<Textarea
|
||||
value={policy}
|
||||
onChange={(event) => setPolicy(event.target.value)}
|
||||
className="h-full min-h-[200px] font-mono text-xs"
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t pt-4">
|
||||
<Button variant="outline" onClick={closeModal}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button variant="default" disabled={submitting} onClick={submitForm}>
|
||||
{submitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner className="size-4" />
|
||||
{t("Submit")}
|
||||
</span>
|
||||
) : (
|
||||
t("Submit")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function UserAccessKeysEditDialog({ open, onOpenChange, userName, row, onSuccess }: EditDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const { getServiceAccount, updateServiceAccount } = useAccessKeys()
|
||||
const { getPolicyByUserName } = usePolicies()
|
||||
|
||||
const [accessKey, setAccessKey] = React.useState("")
|
||||
const [policy, setPolicy] = React.useState("{}")
|
||||
const [parentPolicy, setParentPolicy] = React.useState("{}")
|
||||
const [impliedPolicy, setImpliedPolicy] = React.useState(true)
|
||||
const [expiry, setExpiry] = React.useState<string | null>(null)
|
||||
const [name, setName] = React.useState("")
|
||||
const [description, setDescription] = React.useState("")
|
||||
const [status, setStatus] = React.useState<"on" | "off">("on")
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
|
||||
const minExpiry = React.useMemo(() => dayjs().toISOString(), [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !row?.accessKey) return
|
||||
|
||||
Promise.all([getServiceAccount(row.accessKey), getPolicyByUserName(userName)])
|
||||
.then(([result, parentUserPolicy]) => {
|
||||
const response = result as {
|
||||
impliedPolicy?: boolean
|
||||
policy?: unknown
|
||||
expiration?: string
|
||||
name?: string
|
||||
description?: string
|
||||
accountStatus?: string
|
||||
}
|
||||
|
||||
const parentPolicyValue = JSON.stringify(parentUserPolicy ?? {}, null, 2)
|
||||
const nextImpliedPolicy =
|
||||
typeof response.impliedPolicy === "boolean" ? response.impliedPolicy : response.policy == null
|
||||
|
||||
setAccessKey(row.accessKey)
|
||||
setParentPolicy(parentPolicyValue)
|
||||
setImpliedPolicy(nextImpliedPolicy)
|
||||
setPolicy(nextImpliedPolicy ? parentPolicyValue : formatPolicy(response.policy))
|
||||
setExpiry(
|
||||
response.expiration && response.expiration !== "9999-01-01T00:00:00Z"
|
||||
? dayjs(response.expiration).toISOString()
|
||||
: null,
|
||||
)
|
||||
setName(response.name ?? "")
|
||||
setDescription(response.description ?? "")
|
||||
setStatus((response.accountStatus as "on" | "off") ?? "on")
|
||||
})
|
||||
.catch(() => {
|
||||
message.error(t("Failed to get data"))
|
||||
})
|
||||
}, [getPolicyByUserName, getServiceAccount, message, open, row?.accessKey, t, userName])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (impliedPolicy) {
|
||||
setPolicy(parentPolicy)
|
||||
}
|
||||
}, [impliedPolicy, parentPolicy])
|
||||
|
||||
const closeModal = () => {
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!accessKey) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
let customPolicy: string | null = null
|
||||
if (!impliedPolicy) {
|
||||
try {
|
||||
customPolicy = JSON.stringify(JSON.parse(policy || "{}") as Record<string, unknown>)
|
||||
} catch {
|
||||
message.error(t("Policy format invalid"))
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await updateServiceAccount(accessKey, {
|
||||
newPolicy: customPolicy,
|
||||
newStatus: status,
|
||||
newName: name,
|
||||
newDescription: description,
|
||||
newExpiration: expiry ? dayjs(expiry).toISOString() : null,
|
||||
})
|
||||
message.success(t("Updated successfully"))
|
||||
closeModal()
|
||||
onSuccess()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error(t("Update failed"))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={closeModal}>
|
||||
<DialogContent
|
||||
className={cn("sm:max-w-xl", !impliedPolicy && "sm:max-w-6xl")}
|
||||
onPointerDownOutside={(event) => event.preventDefault()}
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("Edit Key")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex max-h-[80vh] flex-col gap-4 overflow-auto px-2 -mx-2 lg:flex-row">
|
||||
<div
|
||||
className={cn(
|
||||
impliedPolicy ? "flex flex-1 flex-col gap-4" : "flex w-full flex-col gap-4 lg:w-72 lg:shrink-0",
|
||||
)}
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel>{t("Access Key")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input value={accessKey} disabled />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>{t("Expiry")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<DateTimePicker value={expiry} onChange={setExpiry} min={minExpiry} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>{t("Name")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input value={name} onChange={(event) => setName(event.target.value)} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>{t("Description")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={2} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field orientation="responsive" className="items-start gap-3 rounded-md border p-3">
|
||||
<FieldLabel className="text-sm font-medium">{t("Use main account policy")}</FieldLabel>
|
||||
<FieldContent className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("Automatically inherit the main account policy when enabled.")}
|
||||
</p>
|
||||
<Switch checked={impliedPolicy} onCheckedChange={setImpliedPolicy} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field orientation="responsive">
|
||||
<FieldLabel className="text-sm font-medium">{t("Status")}</FieldLabel>
|
||||
<FieldContent className="flex justify-end">
|
||||
<Switch checked={status === "on"} onCheckedChange={(checked) => setStatus(checked ? "on" : "off")} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{!impliedPolicy ? (
|
||||
<div className="flex-1 max-h-[60vh] overflow-auto">
|
||||
<Field className="h-full">
|
||||
<FieldLabel>{t("Current user policy")}</FieldLabel>
|
||||
<FieldContent className="h-full">
|
||||
<Textarea
|
||||
value={policy}
|
||||
onChange={(event) => setPolicy(event.target.value)}
|
||||
className="h-full min-h-[200px] font-mono text-xs"
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t pt-4">
|
||||
<Button variant="outline" onClick={closeModal}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button variant="default" disabled={submitting} onClick={submitForm}>
|
||||
{submitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner className="size-4" />
|
||||
{t("Submit")}
|
||||
</span>
|
||||
) : (
|
||||
t("Submit")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function UserEditAccessKeys({ userName }: UserEditAccessKeysProps) {
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const { canCapability } = usePermissions()
|
||||
const { listAllUserServiceAccounts } = useUsers()
|
||||
const { deleteServiceAccount } = useAccessKeys()
|
||||
|
||||
const canCreateAccessKey = canCapability("accessKeys.create")
|
||||
const canEditAccessKey = canCapability("accessKeys.edit")
|
||||
const canDeleteAccessKey = canCapability("accessKeys.delete")
|
||||
|
||||
const [data, setData] = React.useState<AccessKeyRow[]>([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [searchTerm, setSearchTerm] = React.useState("")
|
||||
const [newDialogOpen, setNewDialogOpen] = React.useState(false)
|
||||
const [editDialogOpen, setEditDialogOpen] = React.useState(false)
|
||||
const [editRow, setEditRow] = React.useState<AccessKeyRow | null>(null)
|
||||
const [noticeOpen, setNoticeOpen] = React.useState(false)
|
||||
const [noticeData, setNoticeData] = React.useState<CredentialsData | null>(null)
|
||||
|
||||
const loadAccounts = React.useCallback(async () => {
|
||||
if (!userName) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = (await listAllUserServiceAccounts(userName)) as { accounts?: AccessKeyRow[] }
|
||||
setData(response?.accounts ?? [])
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error(t("Get Data Failed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [listAllUserServiceAccounts, message, t, userName])
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadAccounts()
|
||||
}, [loadAccounts])
|
||||
|
||||
const filteredData = React.useMemo(() => {
|
||||
if (!searchTerm) return data
|
||||
const term = searchTerm.toLowerCase()
|
||||
return data.filter((row) => row.accessKey.toLowerCase().includes(term))
|
||||
}, [data, searchTerm])
|
||||
|
||||
const handleDelete = React.useCallback(
|
||||
(row: AccessKeyRow) => {
|
||||
dialog.error({
|
||||
title: t("Warning"),
|
||||
content: t("Are you sure you want to delete this key?"),
|
||||
positiveText: t("Confirm"),
|
||||
negativeText: t("Cancel"),
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await deleteServiceAccount(row.accessKey)
|
||||
message.success(t("Delete Success"))
|
||||
await loadAccounts()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error((error as Error)?.message || t("Delete Failed"))
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
[deleteServiceAccount, dialog, loadAccounts, message, t],
|
||||
)
|
||||
|
||||
const columns = React.useMemo<ColumnDef<AccessKeyRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "accessKey",
|
||||
header: () => t("Access Key"),
|
||||
cell: ({ row }) => <span className="font-mono text-sm">{row.original.accessKey}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "expiration",
|
||||
header: () => t("Expiration"),
|
||||
cell: ({ row }) => formatExpiration(row.original.expiration),
|
||||
},
|
||||
{
|
||||
accessorKey: "accountStatus",
|
||||
header: () => t("Status"),
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.original.accountStatus === "on" ? "secondary" : "destructive"}>
|
||||
{row.original.accountStatus === "on" ? t("Available") : t("Disabled")}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: () => t("Name"),
|
||||
cell: ({ row }) => <span>{row.original.name || "-"}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: () => t("Description"),
|
||||
cell: ({ row }) => <span>{row.original.description || "-"}</span>,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => t("Actions"),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
meta: { width: 180 },
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-center gap-2">
|
||||
{canEditAccessKey ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditRow(row.original)
|
||||
setEditDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<RiEdit2Line className="size-4" />
|
||||
<span>{t("Edit")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{canDeleteAccessKey ? (
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => handleDelete(row.original)}>
|
||||
<RiDeleteBin5Line className="size-4" />
|
||||
<span>{t("Delete")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[canDeleteAccessKey, canEditAccessKey, handleDelete, t],
|
||||
)
|
||||
|
||||
const { table } = useDataTable<AccessKeyRow>({
|
||||
data: filteredData,
|
||||
columns,
|
||||
getRowId: (row) => row.accessKey,
|
||||
disablePagination: true,
|
||||
})
|
||||
|
||||
const handleNotice = React.useCallback((result: CredentialsData) => {
|
||||
setNoticeData(result)
|
||||
setNoticeOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleNoticeClose = React.useCallback(() => {
|
||||
setNoticeData(null)
|
||||
void loadAccounts()
|
||||
}, [loadAccounts])
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder={t("Search Access Key")}
|
||||
clearable
|
||||
className="w-full sm:max-w-xs"
|
||||
/>
|
||||
{canCreateAccessKey ? (
|
||||
<Button type="button" variant="outline" onClick={() => setNewDialogOpen(true)}>
|
||||
<RiAddLine className="size-4" />
|
||||
<span>{t("Add Access Key")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={loading}
|
||||
emptyTitle={t("No Access Keys")}
|
||||
emptyDescription={t("Create a new access key to get started.")}
|
||||
tableClass="min-w-full"
|
||||
/>
|
||||
|
||||
<UserAccessKeysNewDialog
|
||||
open={newDialogOpen}
|
||||
onOpenChange={setNewDialogOpen}
|
||||
userName={userName}
|
||||
onSuccess={loadAccounts}
|
||||
onNotice={handleNotice}
|
||||
/>
|
||||
|
||||
<UserAccessKeysEditDialog
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
userName={userName}
|
||||
row={editRow}
|
||||
onSuccess={loadAccounts}
|
||||
/>
|
||||
|
||||
<UserNotice
|
||||
open={noticeOpen}
|
||||
onOpenChange={setNoticeOpen}
|
||||
data={noticeData}
|
||||
onClose={handleNoticeClose}
|
||||
title={t("New access key has been created")}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,9 +18,10 @@ interface UserNoticeProps {
|
||||
onOpenChange: (open: boolean) => void
|
||||
data?: CredentialsData | null
|
||||
onClose?: () => void
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function UserNotice({ open, onOpenChange, data, onClose }: UserNoticeProps) {
|
||||
export function UserNotice({ open, onOpenChange, data, onClose, title }: UserNoticeProps) {
|
||||
const { t } = useTranslation()
|
||||
const accessKey = data?.credentials?.accessKey ?? ""
|
||||
const secretKey = data?.credentials?.secretKey ?? ""
|
||||
@@ -53,7 +54,7 @@ export function UserNotice({ open, onOpenChange, data, onClose }: UserNoticeProp
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("New user has been created")}</DialogTitle>
|
||||
<DialogTitle>{title ?? t("New user has been created")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { resolveDataTablePagination } from "@/lib/data-table-pagination"
|
||||
import type {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
@@ -23,6 +24,7 @@ export interface UseDataTableOptions<TData extends RowData> {
|
||||
data: TData[]
|
||||
columns: ColumnDef<TData, unknown>[]
|
||||
pageSize?: number
|
||||
disablePagination?: boolean
|
||||
manualPagination?: boolean
|
||||
manualSorting?: boolean
|
||||
getRowId?: TableOptions<TData>["getRowId"]
|
||||
@@ -62,12 +64,23 @@ function createSelectColumn<TData extends RowData>(): ColumnDef<TData> {
|
||||
}
|
||||
|
||||
export function useDataTable<TData extends RowData>(options: UseDataTableOptions<TData>): UseDataTableReturn<TData> {
|
||||
const paginationConfig = useMemo(
|
||||
() =>
|
||||
resolveDataTablePagination({
|
||||
disablePagination: options.disablePagination,
|
||||
manualPagination: options.manualPagination,
|
||||
pageSize: options.pageSize,
|
||||
dataLength: options.data.length,
|
||||
}),
|
||||
[options.data.length, options.disablePagination, options.manualPagination, options.pageSize],
|
||||
)
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>(options.initialSorting ?? [])
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: options.pageSize ?? 10,
|
||||
pageSize: paginationConfig.pageSize,
|
||||
})
|
||||
|
||||
const columns = useMemo(() => {
|
||||
@@ -91,7 +104,7 @@ export function useDataTable<TData extends RowData>(options: UseDataTableOptions
|
||||
enableSorting: !options.manualSorting,
|
||||
enableRowSelection: options.enableRowSelection ?? false,
|
||||
manualSorting: options.manualSorting ?? false,
|
||||
manualPagination: options.manualPagination ?? false,
|
||||
manualPagination: paginationConfig.manualPagination,
|
||||
getRowId: options.getRowId,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
@@ -100,7 +113,7 @@ export function useDataTable<TData extends RowData>(options: UseDataTableOptions
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: options.manualPagination ? undefined : getPaginationRowModel(),
|
||||
getPaginationRowModel: paginationConfig.manualPagination ? undefined : getPaginationRowModel(),
|
||||
// Library resets pageIndex during render when data changes, which can trigger setState
|
||||
// before commit (e.g. on route change). We do the reset in useEffect instead.
|
||||
autoResetPageIndex: false,
|
||||
@@ -112,6 +125,12 @@ export function useDataTable<TData extends RowData>(options: UseDataTableOptions
|
||||
setPagination((prev) => (prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 }))
|
||||
}, [options.data])
|
||||
|
||||
useEffect(() => {
|
||||
setPagination((prev) =>
|
||||
prev.pageSize === paginationConfig.pageSize ? prev : { ...prev, pageSize: paginationConfig.pageSize },
|
||||
)
|
||||
}, [paginationConfig.pageSize])
|
||||
|
||||
const selectedRows = table.getSelectedRowModel().rows.map((row) => row.original)
|
||||
const selectedRowIds = table.getSelectedRowModel().rows.map((row) => row.id)
|
||||
|
||||
|
||||
@@ -47,6 +47,80 @@ export function usePolicies() {
|
||||
[api],
|
||||
)
|
||||
|
||||
const getPolicyByUserName = useCallback(
|
||||
async (userName: string) => {
|
||||
const userInfo = (await api.get(`/user-info?accessKey=${encodeURIComponent(userName)}`)) as {
|
||||
policyName?: string
|
||||
memberOf?: string[]
|
||||
}
|
||||
|
||||
const directPolicyNames = userInfo?.policyName?.split(",").filter(Boolean) ?? []
|
||||
const memberOf = userInfo?.memberOf ?? []
|
||||
const groupPoliciesMap: Record<string, string[]> = {}
|
||||
|
||||
if (memberOf.length > 0) {
|
||||
await Promise.all(
|
||||
memberOf.map(async (groupName) => {
|
||||
const groupInfo = (await api.get(`/group?group=${encodeURIComponent(groupName)}`)) as { policy?: string }
|
||||
const groupPolicyNames = groupInfo.policy?.split(",").filter(Boolean) ?? []
|
||||
if (groupPolicyNames.length > 0) {
|
||||
groupPoliciesMap[groupName] = groupPolicyNames
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const allPolicyNames = new Set<string>()
|
||||
directPolicyNames.forEach((policyName) => allPolicyNames.add(policyName))
|
||||
Object.values(groupPoliciesMap).forEach((policyNames) => {
|
||||
policyNames.forEach((policyName) => allPolicyNames.add(policyName))
|
||||
})
|
||||
|
||||
const policyStatement: Record<string, unknown>[] = []
|
||||
|
||||
if (allPolicyNames.size > 0) {
|
||||
await Promise.all(
|
||||
Array.from(allPolicyNames).map(async (policyName) => {
|
||||
const policyInfo = (await getPolicy(policyName)) as { policy?: string }
|
||||
const policyDocument = JSON.parse(policyInfo.policy ?? "{}") as {
|
||||
Statement?: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
if (!Array.isArray(policyDocument.Statement)) return
|
||||
|
||||
const groupOrigins = Object.entries(groupPoliciesMap)
|
||||
.filter(([, policies]) => policies.includes(policyName))
|
||||
.map(([groupName]) => groupName)
|
||||
|
||||
policyDocument.Statement.forEach((statement) => {
|
||||
policyStatement.push({
|
||||
...statement,
|
||||
Sid: statement.Sid ?? policyName,
|
||||
Origin: {
|
||||
type:
|
||||
directPolicyNames.includes(policyName) && groupOrigins.length > 0
|
||||
? "both"
|
||||
: directPolicyNames.includes(policyName)
|
||||
? "direct"
|
||||
: "group",
|
||||
groups: groupOrigins,
|
||||
policyName,
|
||||
},
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
ID: "",
|
||||
Version: "2012-10-17",
|
||||
Statement: policyStatement,
|
||||
}
|
||||
},
|
||||
[api, getPolicy],
|
||||
)
|
||||
|
||||
return {
|
||||
listPolicies,
|
||||
getPolicy,
|
||||
@@ -56,5 +130,6 @@ export function usePolicies() {
|
||||
listGroupsForPolicy,
|
||||
setPolicyMultiple,
|
||||
setUserOrGroupPolicy,
|
||||
getPolicyByUserName,
|
||||
}
|
||||
}
|
||||
|
||||
+8
-2
@@ -2,6 +2,10 @@
|
||||
|
||||
import { useCallback } from "react"
|
||||
import { useApiOptional } from "@/contexts/api-context"
|
||||
import {
|
||||
buildCreateUserServiceAccountRequest,
|
||||
buildListUserServiceAccountsRequest,
|
||||
} from "@/lib/user-service-account-api"
|
||||
|
||||
export function useUsers() {
|
||||
const api = useApiOptional()
|
||||
@@ -88,7 +92,8 @@ export function useUsers() {
|
||||
const listAllUserServiceAccounts = useCallback(
|
||||
async (name: string) => {
|
||||
if (!api) return null
|
||||
return api.get(`/user/${encodeURIComponent(name)}/service-accounts`)
|
||||
const request = buildListUserServiceAccountsRequest(name)
|
||||
return api.get(request.url, { params: request.params })
|
||||
},
|
||||
[api],
|
||||
)
|
||||
@@ -96,7 +101,8 @@ export function useUsers() {
|
||||
const createAUserServiceAccount = useCallback(
|
||||
async (name: string, data: Record<string, unknown>) => {
|
||||
if (!api) return null
|
||||
return api.post(`/user/${encodeURIComponent(name)}/service-accounts`, data)
|
||||
const request = buildCreateUserServiceAccountRequest(name, data)
|
||||
return api.put(request.url, request.body)
|
||||
},
|
||||
[api],
|
||||
)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { resolveDataTablePagination } from "./data-table-pagination.ts"
|
||||
@@ -0,0 +1,18 @@
|
||||
interface ResolveDataTablePaginationOptions {
|
||||
disablePagination?: boolean
|
||||
manualPagination?: boolean
|
||||
pageSize?: number
|
||||
dataLength?: number
|
||||
}
|
||||
|
||||
export function resolveDataTablePagination({
|
||||
disablePagination = false,
|
||||
manualPagination = false,
|
||||
pageSize,
|
||||
dataLength = 0,
|
||||
}: ResolveDataTablePaginationOptions) {
|
||||
return {
|
||||
manualPagination: disablePagination || manualPagination,
|
||||
pageSize: disablePagination ? Math.max(dataLength, 1) : (pageSize ?? 10),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { getAvailableUserEditTabs } from "./user-edit-tabs.ts"
|
||||
@@ -0,0 +1,24 @@
|
||||
export type UserEditTab = "account" | "groups" | "policy" | "access-keys"
|
||||
|
||||
interface UserEditTabAvailability {
|
||||
canEditAccount: boolean
|
||||
canAssignGroups: boolean
|
||||
canEditPolicies: boolean
|
||||
canManageAccessKeys: boolean
|
||||
}
|
||||
|
||||
export function getAvailableUserEditTabs({
|
||||
canEditAccount,
|
||||
canAssignGroups,
|
||||
canEditPolicies,
|
||||
canManageAccessKeys,
|
||||
}: UserEditTabAvailability): UserEditTab[] {
|
||||
const tabs: UserEditTab[] = []
|
||||
|
||||
if (canEditAccount) tabs.push("account")
|
||||
if (canAssignGroups) tabs.push("groups")
|
||||
if (canEditPolicies) tabs.push("policy")
|
||||
if (canManageAccessKeys) tabs.push("access-keys")
|
||||
|
||||
return tabs
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
buildCreateUserServiceAccountRequest,
|
||||
buildListUserServiceAccountsRequest,
|
||||
} from "./user-service-account-api.ts"
|
||||
@@ -0,0 +1,16 @@
|
||||
export function buildListUserServiceAccountsRequest(userName: string) {
|
||||
return {
|
||||
url: "/list-service-accounts",
|
||||
params: { user: userName },
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCreateUserServiceAccountRequest(userName: string, data: Record<string, unknown>) {
|
||||
return {
|
||||
url: "/add-service-account",
|
||||
body: {
|
||||
targetUser: userName,
|
||||
...data,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import test from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { resolveDataTablePagination } from "../../lib/data-table-pagination.js"
|
||||
|
||||
test("resolveDataTablePagination disables pagination and expands page size when requested", () => {
|
||||
assert.deepEqual(
|
||||
resolveDataTablePagination({
|
||||
disablePagination: true,
|
||||
manualPagination: false,
|
||||
pageSize: 10,
|
||||
dataLength: 27,
|
||||
}),
|
||||
{
|
||||
manualPagination: true,
|
||||
pageSize: 27,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test("resolveDataTablePagination keeps the configured page size when pagination stays enabled", () => {
|
||||
assert.deepEqual(
|
||||
resolveDataTablePagination({
|
||||
disablePagination: false,
|
||||
manualPagination: false,
|
||||
pageSize: 10,
|
||||
dataLength: 27,
|
||||
}),
|
||||
{
|
||||
manualPagination: false,
|
||||
pageSize: 10,
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import test from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { getAvailableUserEditTabs } from "../../lib/user-edit-tabs.js"
|
||||
|
||||
test("getAvailableUserEditTabs includes access-keys when the user can manage service accounts", () => {
|
||||
assert.deepEqual(
|
||||
getAvailableUserEditTabs({
|
||||
canEditAccount: true,
|
||||
canAssignGroups: true,
|
||||
canEditPolicies: true,
|
||||
canManageAccessKeys: true,
|
||||
}),
|
||||
["account", "groups", "policy", "access-keys"],
|
||||
)
|
||||
})
|
||||
|
||||
test("getAvailableUserEditTabs omits access-keys when the user lacks service account permissions", () => {
|
||||
assert.deepEqual(
|
||||
getAvailableUserEditTabs({
|
||||
canEditAccount: true,
|
||||
canAssignGroups: true,
|
||||
canEditPolicies: true,
|
||||
canManageAccessKeys: false,
|
||||
}),
|
||||
["account", "groups", "policy"],
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import test from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
buildCreateUserServiceAccountRequest,
|
||||
buildListUserServiceAccountsRequest,
|
||||
} from "../../lib/user-service-account-api.js"
|
||||
|
||||
test("buildListUserServiceAccountsRequest targets the shared list endpoint with a user query", () => {
|
||||
assert.deepEqual(buildListUserServiceAccountsRequest("testuser"), {
|
||||
url: "/list-service-accounts",
|
||||
params: { user: "testuser" },
|
||||
})
|
||||
})
|
||||
|
||||
test("buildCreateUserServiceAccountRequest targets the shared create endpoint with targetUser in the body", () => {
|
||||
assert.deepEqual(
|
||||
buildCreateUserServiceAccountRequest("testuser", {
|
||||
accessKey: "svc-test",
|
||||
secretKey: "secret123",
|
||||
name: "svc",
|
||||
}),
|
||||
{
|
||||
url: "/add-service-account",
|
||||
body: {
|
||||
targetUser: "testuser",
|
||||
accessKey: "svc-test",
|
||||
secretKey: "secret123",
|
||||
name: "svc",
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user