Merge pull request #1183 from Infisical/stv3-permissioning

Move ST sections to members page (now access control)
This commit is contained in:
BlackMagiq
2023-11-18 19:15:51 +02:00
committed by GitHub
21 changed files with 1622 additions and 84 deletions

View File

@@ -100,7 +100,7 @@ const serviceTokenDataV3Schema = new Schema(
default: 7200,
required: true
},
scopes: {
scopes: { // TODO: consider switching this out for roles instead
type: [
{
environment: {

View File

@@ -505,7 +505,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
}
icon="system-outline-96-groups"
>
{t("nav.menu.members")}
Access Control
</MenuItem>
</a>
</Link>
@@ -548,19 +548,6 @@ export const AppLayout = ({ children }: LayoutProps) => {
</MenuItem>
</a>
</Link>
{/* <Link href={`/project/${currentWorkspace?._id}/allowlist`} passHref>
<a>
<MenuItem
isSelected={
router.asPath === `/project/${currentWorkspace?._id}/allowlist`
}
icon="system-outline-126-verified"
>
IP Allowlist
</MenuItem>
</a>
</Link> */}
<Link href={`/project/${currentWorkspace?._id}/audit-logs`} passHref>
<a>
<MenuItem

View File

@@ -1,42 +1,32 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { useTranslation } from "react-i18next";
import { motion } from "framer-motion";
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { useGetRoles } from "@app/hooks/api";
import { TRole } from "@app/hooks/api/roles/types";
import { MemberListTab } from "./components/MemberListTab";
import { ProjectRoleListTab } from "./components/ProjectRoleListTab";
import { ServiceTokenTab } from "./components/ServiceTokenTab";
enum TabSections {
Member = "members",
Roles = "roles"
Roles = "roles",
ServiceTokens = "service-tokens"
}
export const MembersPage = withProjectPermission(
() => {
const { t } = useTranslation();
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?._id || "";
const orgId = currentWorkspace?.organization || "";
const { data: roles, isLoading: isRolesLoading } = useGetRoles({
orgId,
workspaceId
});
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mb-6 w-full py-6 px-6 max-w-7xl mx-auto">
<p className="mr-4 mb-4 text-3xl font-semibold text-white">
{t("settings.members.title")}
Access Control
</p>
<Tabs defaultValue={TabSections.Member}>
<TabList>
<Tab value={TabSections.Member}>Members</Tab>
<Tab value={TabSections.ServiceTokens}>Service Tokens</Tab>
<Tab value={TabSections.Roles}>Roles</Tab>
</TabList>
<TabPanel value={TabSections.Member}>
@@ -47,14 +37,14 @@ export const MembersPage = withProjectPermission(
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<MemberListTab roles={roles as TRole<string>[]} isRolesLoading={isRolesLoading} />
<MemberListTab />
</motion.div>
</TabPanel>
<TabPanel value={TabSections.ServiceTokens}>
<ServiceTokenTab />
</TabPanel>
<TabPanel value={TabSections.Roles}>
<ProjectRoleListTab
roles={roles as TRole<string>[]}
isRolesLoading={isRolesLoading}
/>
<ProjectRoleListTab />
</TabPanel>
</Tabs>
</div>

View File

@@ -46,17 +46,11 @@ import {
useAddUserToWs,
useDeleteUserFromWorkspace,
useGetOrgUsers,
useGetRoles,
useGetUserWsKey,
useGetWorkspaceUsers,
useUpdateUserWorkspaceRole,
useUploadWsKey
} from "@app/hooks/api";
import { TRole } from "@app/hooks/api/roles/types";
type Props = {
roles?: TRole<string>[];
isRolesLoading?: boolean;
};
useUploadWsKey} from "@app/hooks/api";
const addMemberFormSchema = z.object({
orgMembershipId: z.string().trim()
@@ -64,7 +58,7 @@ const addMemberFormSchema = z.object({
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
export const MemberListTab = ({ roles = [], isRolesLoading }: Props) => {
export const MemberListTab = () => {
const { createNotification } = useNotificationContext();
const { t } = useTranslation();
@@ -76,6 +70,10 @@ export const MemberListTab = ({ roles = [], isRolesLoading }: Props) => {
const orgId = currentOrg?._id || "";
const workspaceId = currentWorkspace?._id || "";
const { data: roles, isLoading: isRolesLoading } = useGetRoles({
orgId,
workspaceId
});
const { data: wsKey } = useGetUserWsKey(workspaceId);
const { data: members, isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId);
const { data: orgUsers } = useGetOrgUsers(orgId);
@@ -162,7 +160,7 @@ export const MemberListTab = ({ roles = [], isRolesLoading }: Props) => {
const findRoleFromId = useCallback(
(roleId: string) => {
return roles.find(({ _id: id }) => id === roleId);
return (roles || []).find(({ _id: id }) => id === roleId);
},
[roles]
);
@@ -307,7 +305,7 @@ export const MemberListTab = ({ roles = [], isRolesLoading }: Props) => {
onRoleChange(membershipId, selectedRole)
}
>
{roles
{(roles || [])
.filter(({ slug }) =>
slug === "owner" ? isIamOwner || role === "owner" : true
)

View File

@@ -8,13 +8,8 @@ import { TRole } from "@app/hooks/api/roles/types";
import { ProjectRoleList } from "./components/ProjectRoleList";
import { ProjectRoleModifySection } from "./components/ProjectRoleModifySection";
type Props = {
roles?: TRole<string>[];
isRolesLoading?: boolean;
};
export const ProjectRoleListTab = withProjectPermission(
({ roles = [], isRolesLoading }: Props) => {
() => {
const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["editRole"] as const);
return popUp.editRole.isOpen ? (
@@ -38,11 +33,7 @@ export const ProjectRoleListTab = withProjectPermission(
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<ProjectRoleList
roles={roles}
isRolesLoading={isRolesLoading}
onSelectRole={(role) => handlePopUpOpen("editRole", role)}
/>
<ProjectRoleList onSelectRole={(role) => handlePopUpOpen("editRole", role)} />
</motion.div>
);
},

View File

@@ -25,24 +25,26 @@ import {
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteRole } from "@app/hooks/api";
import { useDeleteRole, useGetRoles } from "@app/hooks/api";
import { TRole } from "@app/hooks/api/roles/types";
type Props = {
isRolesLoading?: boolean;
roles?: TRole<string>[];
onSelectRole: (role?: TRole<string>) => void;
};
export const ProjectRoleList = ({ isRolesLoading, roles = [], onSelectRole }: Props) => {
export const ProjectRoleList = ({ onSelectRole }: Props) => {
const [searchRoles, setSearchRoles] = useState("");
const { createNotification } = useNotificationContext();
const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["deleteRole"] as const);
const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace();
const orgId = currentOrg?._id || "";
const workspaceId = currentWorkspace?._id || "";
const { createNotification } = useNotificationContext();
const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["deleteRole"] as const);
const { data: roles, isLoading: isRolesLoading } = useGetRoles({
orgId,
workspaceId
});
const { mutateAsync: deleteRole } = useDeleteRole();
@@ -62,6 +64,8 @@ export const ProjectRoleList = ({ isRolesLoading, roles = [], onSelectRole }: Pr
}
};
// roles={roles as TRole<string>[]}
return (
<div className="w-full">
<div className="mb-4 flex">
@@ -97,7 +101,7 @@ export const ProjectRoleList = ({ isRolesLoading, roles = [], onSelectRole }: Pr
</THead>
<TBody>
{isRolesLoading && <TableSkeleton columns={4} innerKey="org-roles" />}
{roles?.map((role) => {
{(roles as TRole<string>[])?.map((role) => {
const { _id: id, name, slug } = role;
const isNonMutatable = ["admin", "member", "viewer"].includes(slug);

View File

@@ -0,0 +1,21 @@
import { motion } from "framer-motion";
import {
ServiceTokenSection,
// ServiceTokenV3Section
} from "./components";
export const ServiceTokenTab = () => {
return (
<motion.div
key="panel-service-token"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
{/* <ServiceTokenV3Section /> */}
<ServiceTokenSection />
</motion.div>
);
}

View File

@@ -0,0 +1,375 @@
import crypto from "crypto";
import { useEffect, useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { faCheck, faCopy, faPlus, faTrashCan } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
decryptAssymmetric,
encryptSymmetric
} from "@app/components/utilities/cryptography/crypto";
import {
Button,
Checkbox,
FormControl,
IconButton,
Input,
Modal,
ModalClose,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useToggle } from "@app/hooks";
import { useCreateServiceToken, useGetUserWsKey } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const apiTokenExpiry = [
{ label: "1 Day", value: 86400 },
{ label: "7 Days", value: 604800 },
{ label: "1 Month", value: 2592000 },
{ label: "6 months", value: 15552000 },
{ label: "12 months", value: 31104000 },
{ label: "Never", value: null }
];
const schema = yup.object({
name: yup.string().max(100).required().label("Service Token Name"),
scopes: yup
.array(
yup.object({
environment: yup.string().max(50).required().label("Environment"),
secretPath: yup
.string()
.required()
.default("/")
.label("Secret Path")
.transform((val) =>
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
)
})
)
.min(1)
.required()
.label("Scope"),
expiresIn: yup.string().optional().label("Service Token Expiration"),
permissions: yup
.object()
.shape({
read: yup.boolean().required(),
write: yup.boolean().required()
})
.defined()
.required()
});
export type FormData = yup.InferType<typeof schema>;
type Props = {
popUp: UsePopUpState<["createAPIToken"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["createAPIToken"]>, state?: boolean) => void;
};
export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => {
const { t } = useTranslation();
const { createNotification } = useNotificationContext();
const { currentWorkspace } = useWorkspace();
const {
control,
reset,
handleSubmit,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: yupResolver(schema),
defaultValues: {
scopes: [{
secretPath: "/",
environment: currentWorkspace?.environments?.[0]?.slug
}]
}
});
const { fields: tokenScopes, append, remove } = useFieldArray({ control, name: "scopes" });
const [newToken, setToken] = useState("");
const [isTokenCopied, setIsTokenCopied] = useToggle(false);
const { data: latestFileKey } = useGetUserWsKey(currentWorkspace?._id ?? "");
const createServiceToken = useCreateServiceToken();
const hasServiceToken = Boolean(newToken);
useEffect(() => {
let timer: NodeJS.Timeout;
if (isTokenCopied) {
timer = setTimeout(() => setIsTokenCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isTokenCopied]);
const copyTokenToClipboard = () => {
navigator.clipboard.writeText(newToken);
setIsTokenCopied.on();
};
const onFormSubmit = async ({ name, scopes, expiresIn, permissions }: FormData) => {
try {
if (!currentWorkspace?._id) return;
if (!latestFileKey) return;
const key = decryptAssymmetric({
ciphertext: latestFileKey.encryptedKey,
nonce: latestFileKey.nonce,
publicKey: latestFileKey.sender.publicKey,
privateKey: localStorage.getItem("PRIVATE_KEY") as string
});
const randomBytes = crypto.randomBytes(16).toString("hex");
const { ciphertext, iv, tag } = encryptSymmetric({
plaintext: key,
key: randomBytes
});
const { serviceToken } = await createServiceToken.mutateAsync({
encryptedKey: ciphertext,
iv,
tag,
scopes,
expiresIn: Number(expiresIn),
name,
workspaceId: currentWorkspace._id,
randomBytes,
permissions: Object.entries(permissions)
.filter(([, permissionsValue]) => permissionsValue)
.map(([permissionsKey]) => permissionsKey)
});
setToken(serviceToken);
createNotification({
text: "Successfully created a service token",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to create a service token",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.createAPIToken?.isOpen}
onOpenChange={(open) => {
handlePopUpToggle("createAPIToken", open);
reset();
setToken("");
}}
>
<ModalContent
title={
t("section.token.add-dialog.title", {
target: currentWorkspace?.name
}) as string
}
subTitle={t("section.token.add-dialog.description") as string}
>
{!hasServiceToken ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="name"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label={t("section.token.add-dialog.name")}
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Type your token name" />
</FormControl>
)}
/>
{tokenScopes.map(({ id }, index) => (
<div className="mb-3 flex items-end space-x-2" key={id}>
<Controller
control={control}
name={`scopes.${index}.environment`}
defaultValue={currentWorkspace?.environments?.[0]?.slug}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="mb-0"
label={index === 0 ? "Environment" : undefined}
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{currentWorkspace?.environments.map(({ name, slug }) => (
<SelectItem value={slug} key={slug}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name={`scopes.${index}.secretPath`}
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Secrets Path" : undefined}
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="can be /, /nested/**, /**/deep" />
</FormControl>
)}
/>
<IconButton
className="p-3"
ariaLabel="remove"
colorSchema="danger"
onClick={() => remove(index)}
>
<FontAwesomeIcon icon={faTrashCan} size="sm" />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() =>
append({
environment: currentWorkspace?.environments?.[0]?.slug || "",
secretPath: ""
})
}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add Scope
</Button>
</div>
<Controller
control={control}
name="expiresIn"
defaultValue={String(apiTokenExpiry?.[0]?.value)}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Expiration" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{apiTokenExpiry.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="permissions"
defaultValue={{
read: true,
write: false
}}
render={({ field: { onChange, value }, fieldState: { error } }) => {
const options = [
{
label: "Read (default)",
value: "read"
},
{
label: "Write (optional)",
value: "write"
}
];
return (
<FormControl
label="Permissions"
errorText={error?.message}
isError={Boolean(error)}
>
<>
{options.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={value[optionValue]}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
isDisabled={optionValue === "read"}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</>
</FormControl>
);
}}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
type="submit"
isDisabled={isSubmitting}
isLoading={isSubmitting}
>
Create
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</form>
) : (
<div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{newToken}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isTokenCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{t("common.click-to-copy")}
</span>
</IconButton>
</div>
)}
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,90 @@
import { useTranslation } from "react-i18next";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks";
import { useDeleteServiceToken } from "@app/hooks/api";
import { AddServiceTokenModal } from "./AddServiceTokenModal";
import { ServiceTokenTable } from "./ServiceTokenTable";
type DeleteModalData = { name: string; id: string };
export const ServiceTokenSection = withProjectPermission(
() => {
const { t } = useTranslation();
const { createNotification } = useNotificationContext();
const deleteServiceToken = useDeleteServiceToken();
const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([
"createAPIToken",
"deleteAPITokenConfirmation"
] as const);
const onDeleteApproved = async () => {
try {
deleteServiceToken.mutateAsync(
(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.id
);
createNotification({
text: "Successfully deleted service token",
type: "success"
});
handlePopUpClose("deleteAPITokenConfirmation");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete service token",
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-2 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">
Service Tokens
</p>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.ServiceTokens}
>
{(isAllowed) => (
<Button
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
handlePopUpOpen("createAPIToken");
}}
isDisabled={!isAllowed}
>
Create token
</Button>
)}
</ProjectPermissionCan>
</div>
<p className="mb-8 text-gray-400">{t("section.token.service-tokens-description")}</p>
<ServiceTokenTable handlePopUpOpen={handlePopUpOpen} />
<AddServiceTokenModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteAPITokenConfirmation.isOpen}
title={`Delete ${
(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name || " "
} service token?`}
onChange={(isOpen) => handlePopUpToggle("deleteAPITokenConfirmation", isOpen)}
deleteKey={(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name}
onClose={() => handlePopUpClose("deleteAPITokenConfirmation")}
onDeleteApproved={onDeleteApproved}
/>
</div>
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.ServiceTokens }
);

View File

@@ -0,0 +1,108 @@
import { faFolder, faKey, faTrashCan } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
EmptyState,
IconButton,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useGetUserWsServiceTokens } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteAPITokenConfirmation"]>,
{
name,
id
}: {
name: string;
id: string;
}
) => void;
};
export const ServiceTokenTable = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data, isLoading } = useGetUserWsServiceTokens({
workspaceID: currentWorkspace?._id || ""
});
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Token Name</Th>
<Th>Environment - Secret Path</Th>
<Th>Valid Until</Th>
<Th aria-label="button" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} innerKey="project-service-tokens" />}
{!isLoading &&
data &&
data.map((row) => (
<Tr key={row._id}>
<Td>{row.name}</Td>
<Td>
<div className="mb-2 flex flex-col flex-wrap space-y-1">
{row?.scopes.map(({ secretPath, environment }) => (
<div
key={`${row._id}-${environment}-${secretPath}`}
className="inline-flex items-center space-x-1 rounded-md border border-mineshaft-600 p-1 px-2"
>
<div className="mr-2 border-r border-mineshaft-600 pr-2">{environment}</div>
<FontAwesomeIcon icon={faFolder} size="sm" />
<span className="pl-2">{secretPath}</span>
</div>
))}
</div>
</Td>
<Td>{row.expiresAt && new Date(row.expiresAt).toUTCString()}</Td>
<Td>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.ServiceTokens}
>
{(isAllowed) => (
<IconButton
onClick={() =>
handlePopUpOpen("deleteAPITokenConfirmation", {
name: row.name,
id: row._id
})
}
colorSchema="danger"
ariaLabel="delete"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faTrashCan} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
</Tr>
))}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
<EmptyState title="No service tokens found" icon={faKey} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
};

View File

@@ -0,0 +1 @@
export {ServiceTokenSection} from "./ServiceTokenSection"

View File

@@ -0,0 +1,657 @@
import { useEffect, useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faCheck, faCopy,faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
import { motion } from "framer-motion";
import nacl from "tweetnacl";
import { encodeBase64 } from "tweetnacl-util";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
decryptAssymmetric,
encryptAssymmetric
} from "@app/components/utilities/cryptography/crypto";
import {
Button,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
Select,
SelectItem,
Switch,
Tab,
TabList,
TabPanel,
Tabs,
UpgradePlanModal} from "@app/components/v2";
import {
useSubscription,
useWorkspace
} from "@app/context";
import { useToggle } from "@app/hooks";
import {
useCreateServiceTokenV3,
useGetUserWsKey,
useUpdateServiceTokenV3
} from "@app/hooks/api";
import {
Permission
} from "@app/hooks/api/serviceTokens/enums";
import {
ServiceTokenV3Scope,
ServiceTokenV3TrustedIp
} from "@app/hooks/api/serviceTokens/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
enum TabSections {
General = "general",
Advanced = "advanced"
}
const expirations = [
{ label: "Never", value: "" },
{ label: "1 day", value: "86400" },
{ label: "7 days", value: "604800" },
{ label: "1 month", value: "2592000" },
{ label: "6 months", value: "15552000" },
{ label: "12 months", value: "31104000" }
];
const permissionsMap: {
[key: string]: Permission[]
} = {
"read": [Permission.READ],
"readWrite": [Permission.READ, Permission.WRITE],
}
const schema = yup.object({
name: yup.string().required("ST V3 name is required"),
expiresIn: yup.string(),
accessTokenTTL: yup
.string()
.test("is-positive-integer", "Access Token TTL must be a positive integer", (value) => {
if (typeof value === "undefined") {
return false;
}
const num = parseInt(value, 10);
return !Number.isNaN(num) && num > 0 && String(num) === value;
})
.required("Access Token TTL is required"),
scopes: yup
.array(
yup.object({
permission: yup.string().oneOf(Object.keys(permissionsMap), "Invalid permission").required().label("Permission"),
environment: yup.string().max(50).required().label("Environment"),
secretPath: yup
.string()
.required()
.default("/")
.label("Secret Path")
.transform((val) =>
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
)
})
)
.min(1)
.required()
.label("Scope"),
trustedIps: yup
.array(
yup.object({
ipAddress: yup.string().max(50).required().label("IP Address")
})
)
.min(1)
.required()
.label("Trusted IP"),
isRefreshTokenRotationEnabled: yup.boolean().default(false)
}).required();
export type FormData = yup.InferType<typeof schema>;
type Props = {
popUp: UsePopUpState<["serviceTokenV3", "upgradePlan"]>;
handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["serviceTokenV3", "upgradePlan"]>, state?: boolean) => void;
};
export const AddServiceTokenV3Modal = ({
popUp,
handlePopUpOpen,
handlePopUpToggle
}: Props) => {
const [newServiceTokenJSON, setNewServiceTokenJSON] = useState("");
const [isServiceTokenJSONCopied, setIsServiceTokenJSONCopied] = useToggle(false);
const { subscription } = useSubscription();
const { currentWorkspace } = useWorkspace();
const { data: latestFileKey } = useGetUserWsKey(currentWorkspace?._id ?? "");
const { mutateAsync: createMutateAsync } = useCreateServiceTokenV3();
const { mutateAsync: updateMutateAsync } = useUpdateServiceTokenV3();
const { createNotification } = useNotificationContext();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: yupResolver(schema),
defaultValues: {
name: "",
accessTokenTTL: "7200",
scopes: [{
permission: "read",
environment: currentWorkspace?.environments?.[0]?.slug,
secretPath: "/",
}],
trustedIps: [{
ipAddress: "0.0.0.0/0"
}]
}
});
useEffect(() => {
let timer: NodeJS.Timeout;
if (isServiceTokenJSONCopied) {
timer = setTimeout(() => setIsServiceTokenJSONCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [setIsServiceTokenJSONCopied]);
const copyTokenToClipboard = () => {
navigator.clipboard.writeText(newServiceTokenJSON);
setIsServiceTokenJSONCopied.on();
};
useEffect(() => {
const serviceTokenData = popUp?.serviceTokenV3?.data as {
serviceTokenDataId: string;
name: string;
scopes: ServiceTokenV3Scope[];
trustedIps: ServiceTokenV3TrustedIp[];
accessTokenTTL: number;
isRefreshTokenRotationEnabled: boolean;
};
if (serviceTokenData) {
reset({
name: serviceTokenData.name,
scopes: serviceTokenData.scopes.map(({
environment,
secretPath,
permissions
}: ServiceTokenV3Scope) => {
let permission = "read";
if (permissions.includes(Permission.WRITE)) {
permission = "readWrite";
}
return ({
environment,
secretPath,
permission
})
}),
trustedIps: serviceTokenData.trustedIps.map(({
ipAddress,
prefix
}: ServiceTokenV3TrustedIp) => {
return ({
ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`
});
}),
accessTokenTTL: String(serviceTokenData.accessTokenTTL),
isRefreshTokenRotationEnabled: serviceTokenData.isRefreshTokenRotationEnabled
});
} else {
reset({
name: "",
accessTokenTTL: "7200",
scopes: [{
permission: "read",
environment: currentWorkspace?.environments?.[0]?.slug,
secretPath: "/",
}],
trustedIps: [{
ipAddress: "0.0.0.0/0"
}]
});
}
}, [popUp?.serviceTokenV3?.data]);
const { fields: tokenScopes, append, remove } = useFieldArray({ control, name: "scopes" });
const { fields: tokenTrustedIps, append: appendTrustedIp, remove: removeTrustedIp } = useFieldArray({ control, name: "trustedIps" });
const onFormSubmit = async ({
name,
expiresIn,
accessTokenTTL,
scopes,
trustedIps,
isRefreshTokenRotationEnabled
}: FormData) => {
try {
const serviceTokenData = popUp?.serviceTokenV3?.data as {
serviceTokenDataId: string;
name: string;
scopes: any;
};
// convert read/readWrite permission => ["read", "write"] format
const reformattedScopes = scopes.map((scope) => {
return ({
environment: scope.environment,
secretPath: scope.secretPath,
permissions: permissionsMap[scope.permission]
});
});
if (serviceTokenData) {
// update
await updateMutateAsync({
serviceTokenDataId: serviceTokenData.serviceTokenDataId,
name,
scopes: reformattedScopes,
trustedIps,
expiresIn: expiresIn === "" ? undefined : Number(expiresIn),
accessTokenTTL: Number(accessTokenTTL),
isRefreshTokenRotationEnabled
});
handlePopUpToggle("serviceTokenV3", false);
} else {
// create
if (!currentWorkspace?._id) return;
if (!latestFileKey) return;
const pair = nacl.box.keyPair();
const secretKeyUint8Array = pair.secretKey;
const publicKeyUint8Array = pair.publicKey;
const privateKey = encodeBase64(secretKeyUint8Array);
const publicKey = encodeBase64(publicKeyUint8Array);
const key = decryptAssymmetric({
ciphertext: latestFileKey.encryptedKey,
nonce: latestFileKey.nonce,
publicKey: latestFileKey.sender.publicKey,
privateKey: localStorage.getItem("PRIVATE_KEY") as string
});
const { ciphertext, nonce } = encryptAssymmetric({
plaintext: key,
publicKey,
privateKey: localStorage.getItem("PRIVATE_KEY") as string
});
const { refreshToken } = await createMutateAsync({
name,
workspaceId: currentWorkspace._id,
publicKey,
scopes: reformattedScopes,
trustedIps,
expiresIn: expiresIn === "" ? undefined : Number(expiresIn),
accessTokenTTL: Number(accessTokenTTL),
encryptedKey: ciphertext,
nonce,
isRefreshTokenRotationEnabled
});
const downloadData = {
public_key: publicKey,
private_key: privateKey,
refresh_token: refreshToken
};
const serviceTokenJSON = JSON.stringify(downloadData, null, 2);
setNewServiceTokenJSON(serviceTokenJSON);
const blob = new Blob([serviceTokenJSON], { type: "application/json" });
const href = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = href;
link.download = `infisical_${name}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
createNotification({
text: `Successfully ${popUp?.serviceTokenV3?.data ? "updated" : "created"} ST V3`,
type: "success"
});
reset();
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${popUp?.serviceTokenV3?.data ? "updated" : "created"} ST V3`,
type: "error"
});
}
}
const hasServiceTokenJSON = Boolean(newServiceTokenJSON);
return (
<Modal
isOpen={popUp?.serviceTokenV3?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("serviceTokenV3", isOpen);
reset();
setNewServiceTokenJSON("");
}}
>
<ModalContent title={`${popUp?.serviceTokenV3?.data ? "Update" : "Create"} Service Token V3`}>
{!hasServiceTokenJSON ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Tabs defaultValue={TabSections.General}>
<TabList>
<div className="flex flex-row border-b border-mineshaft-600 w-full">
<Tab value={TabSections.General}>General</Tab>
<Tab value={TabSections.Advanced}>Advanced</Tab>
</div>
</TabList>
<TabPanel value={TabSections.General}>
<motion.div
key="panel-1"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
placeholder="My ST V3"
/>
</FormControl>
)}
/>
{tokenScopes.map(({ id }, index) => (
<div className="flex items-end space-x-2 mb-3" key={id}>
<Controller
control={control}
name={`scopes.${index}.permission`}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="mb-0"
label={index === 0 ? "Permission" : undefined}
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-36"
>
<SelectItem value="read" key="st-v3-read">
Read
</SelectItem>
<SelectItem value="readWrite" key="st-v3-write">
Read &amp; Write
</SelectItem>
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name={`scopes.${index}.environment`}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="mb-0"
label={index === 0 ? "Environment" : undefined}
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-36"
>
{currentWorkspace?.environments.map(({ name, slug }) => (
<SelectItem value={slug} key={slug}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name={`scopes.${index}.secretPath`}
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Secrets Path" : undefined}
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="can be /, /nested/**, /**/deep" />
</FormControl>
)}
/>
<IconButton
onClick={() => remove(index)}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="p-3"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() =>
append({
permission: "read",
environment: currentWorkspace?.environments?.[0]?.slug || "",
secretPath: "/"
})
}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add Scope
</Button>
</div>
<Controller
control={control}
name="expiresIn"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label={`${popUp?.serviceTokenV3?.data ? "Update" : ""} Refresh Token Expires In`}
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{expirations.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={`api-key-expiration-${label}`}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</motion.div>
</TabPanel>
<TabPanel value={TabSections.Advanced}>
<div>
{tokenTrustedIps.map(({ id }, index) => (
<div className="flex items-end space-x-2 mb-3" key={id}>
<Controller
control={control}
name={`trustedIps.${index}.ipAddress`}
defaultValue="0.0.0.0/0"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Trusted IP" : undefined}
isError={Boolean(error)}
errorText={error?.message}
>
<Input
value={field.value}
onChange={(e) => {
if (subscription?.ipAllowlisting) {
field.onChange(e);
return;
}
handlePopUpOpen("upgradePlan");
}}
placeholder="123.456.789.0"
/>
</FormControl>
);
}}
/>
<IconButton
onClick={() => {
if (subscription?.ipAllowlisting) {
removeTrustedIp(index);
return;
}
handlePopUpOpen("upgradePlan");
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="p-3"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() => {
if (subscription?.ipAllowlisting) {
appendTrustedIp({
ipAddress: "0.0.0.0/0"
})
return;
}
handlePopUpOpen("upgradePlan");
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add IP Address
</Button>
</div>
<Controller
control={control}
defaultValue="7200"
name="accessTokenTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Token TTL (seconds)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
placeholder="7200"
/>
</FormControl>
)}
/>
<div className="mt-8">
<Controller
control={control}
name="isRefreshTokenRotationEnabled"
render={({ field: { onChange, value } }) => (
<Switch
id="label-refresh-token-rotation"
onCheckedChange={(isChecked) => onChange(isChecked)}
isChecked={value}
>
Refresh Token Rotation
</Switch>
)}
/>
<p className="mt-4 text-sm font-normal text-mineshaft-400">When enabled, as a result of exchanging a refresh token, a new refresh token will be issued and the existing token will be invalidated.</p>
</div>
</div>
</TabPanel>
</Tabs>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{popUp?.serviceTokenV3?.data ? "Update" : "Create"}
</Button>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</div>
</form>
) : (
<div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{newServiceTokenJSON}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isServiceTokenJSONCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Click to copy
</span>
</IconButton>
</div>
)}
<UpgradePlanModal
isOpen={popUp?.upgradePlan?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use IP allowlisting if you switch to Infisical's Pro plan."
/>
</ModalContent>
</Modal>
);
}

View File

@@ -0,0 +1,98 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import {
useDeleteServiceTokenV3
} from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { AddServiceTokenV3Modal } from "./AddServiceTokenV3Modal";
import { ServiceTokenV3Table } from "./ServiceTokenV3Table";
export const ServiceTokenV3Section = withProjectPermission(
() => {
const { createNotification } = useNotificationContext();
const { mutateAsync: deleteMutateAsync } = useDeleteServiceTokenV3();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"serviceTokenV3",
"deleteServiceTokenV3",
"upgradePlan"
] as const);
const onDeleteServiceTokenDataSubmit = async (serviceTokenDataId: string) => {
try {
await deleteMutateAsync({
serviceTokenDataId
});
createNotification({
text: "Successfully deleted service token v3",
type: "success"
});
handlePopUpClose("deleteServiceTokenV3");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete service token v3",
type: "error"
});
}
}
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex justify-between mb-8">
<p className="text-xl font-semibold text-mineshaft-100">
Service Tokens V3 (Beta)
</p>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.ServiceTokens}
>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("serviceTokenV3")}
isDisabled={!isAllowed}
>
Create token
</Button>
)}
</ProjectPermissionCan>
</div>
<ServiceTokenV3Table
handlePopUpOpen={handlePopUpOpen}
/>
<AddServiceTokenV3Modal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal
isOpen={popUp.deleteServiceTokenV3.isOpen}
title={`Are you sure want to delete ${
(popUp?.deleteServiceTokenV3?.data as { name: string })?.name || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteServiceTokenV3", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onDeleteServiceTokenDataSubmit(
(popUp?.deleteServiceTokenV3?.data as { serviceTokenDataId: string })?.serviceTokenDataId
)
}
/>
</div>
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.ServiceTokens }
);

View File

@@ -0,0 +1,232 @@
import { faKey, faPencil,faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
EmptyState,
IconButton,
Switch,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub , useWorkspace } from "@app/context";
import {
useGetWorkspaceServiceTokenDataV3,
useUpdateServiceTokenV3
} from "@app/hooks/api";
import { Permission } from "@app/hooks/api/serviceTokens/enums"
import { ServiceTokenV3Scope, ServiceTokenV3TrustedIp } from "@app/hooks/api/serviceTokens/types"
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteServiceTokenV3", "serviceTokenV3"]>,
data?: {
serviceTokenDataId?: string;
name?: string;
scopes?: ServiceTokenV3Scope[];
trustedIps?: ServiceTokenV3TrustedIp[];
accessTokenTTL?: number;
isRefreshTokenRotationEnabled?: boolean;
}
) => void;
};
export const ServiceTokenV3Table = ({
handlePopUpOpen
}: Props) => {
const { createNotification } = useNotificationContext();
const { currentWorkspace } = useWorkspace();
const { data, isLoading } = useGetWorkspaceServiceTokenDataV3(currentWorkspace?._id || "");
const { mutateAsync: updateMutateAsync } = useUpdateServiceTokenV3();
const handleToggleServiceTokenDataStatus = async ({
serviceTokenDataId,
isActive
}: {
serviceTokenDataId: string;
isActive: boolean;
}) => {
try {
await updateMutateAsync({
serviceTokenDataId,
isActive
});
createNotification({
text: `Successfully ${isActive ? "enabled" : "disabled"} service token v3`,
type: "success"
});
} catch (err) {
console.log(err);
createNotification({
text: `Failed to ${isActive ? "enable" : "disable"} service token v3`,
type: "error"
});
}
}
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Status</Th>
<Th>Scopes</Th>
<Th>Trusted IPs</Th>
<Th>Access Token TTL</Th>
<Th>Created At</Th>
<Th>Valid Until</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={7} innerKey="service-tokens" />}
{!isLoading &&
data &&
data.length > 0 &&
data.map(({
_id,
name,
isActive,
scopes,
trustedIps,
createdAt,
expiresAt,
accessTokenTTL,
isRefreshTokenRotationEnabled
}) => {
return (
<Tr className="h-10" key={`st-v3-${_id}`}>
<Td>{name}</Td>
<Td>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.ServiceTokens}
>
{(isAllowed) => (
<Switch
id={`enable-service-token-${_id}`}
onCheckedChange={(value) => handleToggleServiceTokenDataStatus({
serviceTokenDataId: _id,
isActive: value
})}
isChecked={isActive}
isDisabled={!isAllowed}
>
<p className="w-12 mr-4">{isActive ? "Active" : "Inactive"}</p>
</Switch>
)}
</ProjectPermissionCan>
</Td>
<Td>
{scopes.map((scope) => {
let permissionText = "read"
if (
scope.permissions.includes(Permission.WRITE) &&
scope.permissions.includes(Permission.READ)
) {
permissionText = "readWrite";
}
return (
<p key={`service-token-${_id}-scope-${scope.environment}-${scope.secretPath}`}>
<span className="font-bold">
{permissionText}
</span>
{` @${scope.environment} - ${scope.secretPath}`}
</p>
);
})}
</Td>
<Td>
{trustedIps.map(({
_id: trustedIpId,
ipAddress,
prefix
}) => {
return (
<p key={`service-token-${_id}-}-trusted-ip-${trustedIpId}`}>
{`${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`}
</p>
);
})}
</Td>
<Td>{accessTokenTTL}</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
<Td>{expiresAt ? format(new Date(expiresAt), "yyyy-MM-dd") : "-"}</Td>
<Td className="flex justify-end">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.ServiceTokens}
>
{(isAllowed) => (
<IconButton
onClick={async () => {
handlePopUpOpen("serviceTokenV3", {
serviceTokenDataId: _id,
name,
scopes,
trustedIps,
accessTokenTTL,
isRefreshTokenRotationEnabled
});
}}
size="lg"
colorSchema="primary"
variant="plain"
ariaLabel="update"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.ServiceTokens}
>
{(isAllowed) => (
<IconButton
onClick={() => {
handlePopUpOpen("deleteServiceTokenV3", {
serviceTokenDataId: _id,
name
});
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
</Tr>
);
})}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={7}>
<EmptyState title="No service token v3 on file" icon={faKey} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
}

View File

@@ -0,0 +1 @@
export { ServiceTokenV3Section } from "./ServiceTokenV3Section";

View File

@@ -0,0 +1,2 @@
export { ServiceTokenSection } from "./ServiceTokenSection";
export { ServiceTokenV3Section } from "./ServiceTokenV3Section";

View File

@@ -0,0 +1 @@
export { ServiceTokenTab } from "./ServiceTokenTab";

View File

@@ -3,12 +3,10 @@ import { useTranslation } from "react-i18next";
import { Tab } from "@headlessui/react";
import { ProjectGeneralTab } from "./components/ProjectGeneralTab";
import { ProjectServiceTokensTab } from "./components/ProjectServiceTokensTab";
import { WebhooksTab } from "./components/WebhooksTab";
const tabs = [
{ name: "General", key: "tab-project-general" },
{ name: "Service Tokens", key: "tab-project-service-tokens" },
{ name: "Webhooks", key: "tab-project-webhooks" }
];
@@ -41,9 +39,6 @@ export const ProjectSettingsPage = () => {
<Tab.Panel>
<ProjectGeneralTab />
</Tab.Panel>
<Tab.Panel>
<ProjectServiceTokensTab />
</Tab.Panel>
<Tab.Panel>
<WebhooksTab />
</Tab.Panel>

View File

@@ -1,11 +0,0 @@
import { ServiceTokenSection } from "../ServiceTokenSection";
// import { ServiceTokenV3Section } from "../ServiceTokenV3Section";
export const ProjectServiceTokensTab = () => {
return (
<>
{/* <ServiceTokenV3Section /> */}
<ServiceTokenSection />
</>
);
}

View File

@@ -1 +0,0 @@
export { ProjectServiceTokensTab } from "./ProjectServiceTokensTab";

View File

@@ -3,5 +3,4 @@ export { DeleteProjectSection } from "./DeleteProjectSection";
export { E2EESection } from "./E2EESection";
export { EnvironmentSection } from "./EnvironmentSection";
export { ProjectNameChangeSection } from "./ProjectNameChangeSection";
export { SecretTagsSection } from "./SecretTagsSection";
export { ServiceTokenSection } from "./ServiceTokenSection";
export { SecretTagsSection } from "./SecretTagsSection";