diff --git a/app/(dashboard)/access-keys/page.tsx b/app/(dashboard)/access-keys/page.tsx index eaaf91c..d9e7ea3 100644 --- a/app/(dashboard)/access-keys/page.tsx +++ b/app/(dashboard)/access-keys/page.tsx @@ -264,7 +264,13 @@ export default function AccessKeysPage() { onSuccess={listUserAccounts} /> - + ) } diff --git a/components/user/edit-form.tsx b/components/user/edit-form.tsx index 0ccf7f4..b5a3eaa 100644 --- a/components/user/edit-form.tsx +++ b/components/user/edit-form.tsx @@ -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 ( e.preventDefault()} onInteractOutside={(e) => e.preventDefault()} > @@ -237,6 +242,7 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor {canEditAccount ? {t("Account")} : null} {canAssignGroups ? {t("Groups")} : null} {canEditPolicies ? {t("Policies")} : null} + {canManageAccessKeys ? {t("Access Keys")} : null} {canEditAccount ? ( @@ -279,6 +285,12 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor /> ) : null} + + {canManageAccessKeys ? ( + + + + ) : null} diff --git a/components/user/edit/access-keys.tsx b/components/user/edit/access-keys.tsx new file mode 100644 index 0000000..2aef1eb --- /dev/null +++ b/components/user/edit/access-keys.tsx @@ -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, 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(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) + } 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 ( + + event.preventDefault()} + onInteractOutside={(event) => event.preventDefault()} + > + + {t("Create Key")} + + +
+
+ + {t("Access Key")} + + setAccessKey(event.target.value)} + autoComplete="off" + /> + + {errors.accessKey ? ( + {errors.accessKey} + ) : null} + + + + {t("Secret Key")} + + setSecretKey(event.target.value)} + autoComplete="off" + /> + + {errors.secretKey ? ( + {errors.secretKey} + ) : null} + + + + + {t("Expiry")}({t("empty is indicates permanent validity")}) + + + + + + + + {t("Name")} + + setName(event.target.value)} + autoComplete="off" + /> + + {errors.name ? {errors.name} : null} + + + + {t("Description")} + +