Checkpoint service account functionality, added UI and general backend structure

This commit is contained in:
Tuan Dang
2023-03-27 22:00:10 +07:00
parent 3a0ce7c084
commit d36d7bfce6
39 changed files with 1710 additions and 356 deletions
+1
View File
@@ -176,6 +176,7 @@ const Layout = ({ children }: LayoutProps) => {
useEffect(() => {
// Put a user in a workspace if they're not in one yet
const putUserInWorkSpace = async () => {
if (tempLocalStorage('orgData.id') === '') {
const userOrgs = await getOrganizations();
@@ -3,21 +3,26 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useOrganization, useWorkspace } from '@app/context';
// TODO: make links clickable and clean up
/**
* This is the component at the top of almost every page.
* It shows how to navigate to a certain page.
* It future these links should also be clickable and hoverable
* @param obj
* @param obj.pageName - Name of the page
* @param obj.isProjectRelated - whether this page is related to project or now (determine if it's 2 or 3 navigation steps)
* @param obj.isProjectRelated - whether or not this page is related to project (determine if it's 2 or 3 navigation steps)
* @param obj.isOrganizationRelated - whether or not this page is related to organization (determine if it's 2 or 3 navigation steps)
* @returns
*/
export default function NavHeader({
pageName,
isProjectRelated
isProjectRelated,
isOrganizationRelated
}: {
pageName: string;
isProjectRelated?: boolean;
isOrganizationRelated?: boolean;
}): JSX.Element {
const { currentWorkspace } = useWorkspace();
const { currentOrg } = useOrganization();
@@ -34,6 +39,12 @@ export default function NavHeader({
<div className="text-sm font-semibold text-primary">{currentWorkspace?.name}</div>
</>
)}
{isOrganizationRelated && (
<>
<FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-3 text-sm text-gray-400" />
<div className="text-sm font-semibold text-primary">Organization Settings</div>
</>
)}
<FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-3 text-sm text-gray-400" />
<div className="text-sm text-gray-400">{pageName}</div>
</div>
@@ -5,6 +5,21 @@ import aes from './aes-256-gcm';
const nacl = require('tweetnacl');
nacl.util = require('tweetnacl-util');
/**
* Return new base64, NaCl, public-private key pair.
* @returns {Object} obj
* @returns {String} obj.publicKey - base64, NaCl, public key
* @returns {String} obj.privateKey - base64, NaCl, private key
*/
const generateKeyPair = () => {
const pair = nacl.box.keyPair();
return ({
publicKey: nacl.util.encodeBase64(pair.publicKey),
privateKey: nacl.util.encodeBase64(pair.secretKey)
});
}
type EncryptAsymmetricProps = {
plaintext: string;
publicKey: string;
@@ -189,5 +204,5 @@ export {
decryptSymmetric,
deriveArgonKey,
encryptAssymmetric,
encryptSymmetric
};
encryptSymmetric,
generateKeyPair};
+1
View File
@@ -2,6 +2,7 @@ export * from './auth';
export * from './incidentContacts';
export * from './keys';
export * from './organization';
export * from './serviceAccounts';
export * from './serviceTokens';
export * from './subscriptions';
export * from './tags';
@@ -0,0 +1,9 @@
export {
useCreateServiceAccount,
useCreateServiceAccountProjectLevelPermissions,
useDeleteServiceAccount,
useDeleteServiceAccountProjectLevelPermissions,
useGetServiceAccountById,
useGetServiceAccountProjectLevelPermissions,
useGetServiceAccounts,
useRenameServiceAccount} from './queries';
@@ -0,0 +1,146 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiRequest } from '@app/config/request';
import {
CreateServiceAccountDTO,
CreateServiceAccountRes,
CreateServiceAccountWorkspacePermissionsDTO,
DeleteServiceAccountRes,
DeleteServiceAccountWorkspacePermissionsDTO,
DeleteServiceAccountWorkspacePermissionsRes,
RenameServiceAccountDTO,
RenameServiceAccountRes,
ServiceAccount,
ServiceAccountWorkspacePermissions} from './types';
const serviceAccountKeys = {
getServiceAccountById: (serviceAccountId: string) => [{ serviceAccountId }, 'service-account'] as const,
getServiceAccounts: (organizationID: string) => [{ organizationID }, 'service-accounts'] as const,
getServiceAccountProjectLevelPermissions: (serviceAccountId: string) => [{ serviceAccountId }, 'service-account-project-level-permissions'] as const
}
const fetchServiceAccounts = async (organizationID: string) => {
const { data } = await apiRequest.get<{ serviceAccounts: ServiceAccount[] }>(
`/api/v2/organizations/${organizationID}/service-accounts`
);
return data.serviceAccounts;
}
const fetchServiceAccountById = async (serviceAccountId: string) => {
const { data } = await apiRequest.get<{ serviceAccount: ServiceAccount }>(
`/api/v2/service-accounts/${serviceAccountId}`
);
return data.serviceAccount;
}
export const useGetServiceAccounts = (organizationID: string) =>
useQuery({
queryKey: serviceAccountKeys.getServiceAccounts(organizationID),
queryFn: () => fetchServiceAccounts(organizationID),
enabled: Boolean(organizationID)
});
export const useGetServiceAccountById = (serviceAccountId: string) => {
return useQuery({
queryKey: serviceAccountKeys.getServiceAccountById(serviceAccountId),
queryFn: () => fetchServiceAccountById(serviceAccountId),
enabled: true
});
}
export const useCreateServiceAccount = () => {
const queryClient = useQueryClient();
return useMutation<CreateServiceAccountRes, {}, CreateServiceAccountDTO>({
mutationFn: async (body) => {
const { data } = await apiRequest.post('/api/v2/service-accounts/', body);
return data;
},
onSuccess: ({ serviceAccount }) => {
queryClient.invalidateQueries(serviceAccountKeys.getServiceAccounts(serviceAccount.organization));
}
});
}
export const useRenameServiceAccount = () => {
const queryClient = useQueryClient();
return useMutation<RenameServiceAccountRes, {}, RenameServiceAccountDTO>({
mutationFn: async ({ serviceAccountId, name }) => {
const { data: { serviceAccount } } = await apiRequest.patch(`/api/v2/service-accounts/${serviceAccountId}/name`, { name });
return serviceAccount;
},
onSuccess: (serviceAccount) => {
queryClient.invalidateQueries(serviceAccountKeys.getServiceAccountById(serviceAccount._id));
queryClient.invalidateQueries(serviceAccountKeys.getServiceAccounts(serviceAccount.organization));
}
});
}
export const useDeleteServiceAccount = () => {
const queryClient = useQueryClient();
return useMutation<DeleteServiceAccountRes, {}, string>({
mutationFn: async (serviceAccountId) => {
const { data: { serviceAccount } } = await apiRequest.delete(`/api/v2/service-accounts/${serviceAccountId}`);
return serviceAccount;
},
onSuccess: ({ organization }) => {
queryClient.invalidateQueries(serviceAccountKeys.getServiceAccounts(organization));
}
});
}
const fetchServiceAccountProjectLevelPermissions = async (serviceAccountId: string) => {
const { data: { permissions } } = await apiRequest.get<{ permissions: ServiceAccountWorkspacePermissions[] }>(
`/api/v2/service-accounts/${serviceAccountId}/permissions/workspace`
);
console.log('fetchServiceAccountProjectLevelPermissions');
console.log('prrr: ', permissions);
return permissions;
}
export const useGetServiceAccountProjectLevelPermissions = (serviceAccountId: string) => {
return useQuery({
queryKey: serviceAccountKeys.getServiceAccountProjectLevelPermissions(serviceAccountId),
queryFn: () => fetchServiceAccountProjectLevelPermissions(serviceAccountId),
enabled: true
});
}
export const useCreateServiceAccountProjectLevelPermissions = () => {
const queryClient = useQueryClient();
return useMutation<CreateServiceAccountRes, {}, CreateServiceAccountWorkspacePermissionsDTO>({
mutationFn: async (body) => {
const { data: { permissions } } = await apiRequest.post(`/api/v2/service-accounts/${body.serviceAccountId}/permissions/workspace`, body);
return permissions;
},
onSuccess: ({ serviceAccount }) => {
queryClient.invalidateQueries(serviceAccountKeys.getServiceAccountProjectLevelPermissions(serviceAccount));
}
});
}
export const useDeleteServiceAccountProjectLevelPermissions = () => {
const queryClient = useQueryClient();
return useMutation<DeleteServiceAccountWorkspacePermissionsRes, {}, DeleteServiceAccountWorkspacePermissionsDTO>({
mutationFn: async ({ serviceAccountId, serviceAccountWorkspacePermissionsId }) => {
const { data: { permissions } } = await apiRequest.delete(`/api/v2/service-accounts/${serviceAccountId}/permissions/workspace/${serviceAccountWorkspacePermissionsId}`);
console.log('useDeleteServiceAccountProjectLevelPermissions');
console.log('permissions: ', permissions);
return permissions;
},
onSuccess: ({ serviceAccount }) => {
console.log('onSuccess3: ', serviceAccount);
queryClient.invalidateQueries(serviceAccountKeys.getServiceAccountProjectLevelPermissions(serviceAccount));
// queryClient.invalidateQueries(serviceAccountKeys.getServiceAccounts(organization));
}
});
}
@@ -0,0 +1,66 @@
export type ServiceAccount = {
_id: string;
name: string;
organization: string;
user: string;
publicKey: string;
expiresAt: string;
}
export type CreateServiceAccountDTO = {
name: string;
organizationId: string;
publicKey: string;
expiresIn: number;
}
export type CreateServiceAccountRes = {
serviceAccount: ServiceAccount;
serviceAccountAccessKey: string;
}
export type DeleteServiceAccountRes = {
serviceAccount: ServiceAccount;
}
export type RenameServiceAccountDTO = {
serviceAccountId: string;
name: string;
}
export type RenameServiceAccountRes = {
serviceAccount: ServiceAccount;
}
export type ServiceAccountWorkspacePermissions = {
serviceAccount: string;
workspace: string;
environment: string;
canRead: boolean;
canWrite: boolean;
canUpdate: boolean;
canDelete: boolean;
}
export type CreateServiceAccountWorkspacePermissionsDTO = {
serviceAccountId: string;
workspaceId: string;
environment: string;
canRead: boolean;
canWrite: boolean;
canUpdate: boolean;
canDelete: boolean;
}
export type CreateServiceAccountWorkspacePermissionsRes = {
permissions: ServiceAccountWorkspacePermissions
}
export type DeleteServiceAccountWorkspacePermissionsDTO = {
serviceAccountId: string;
serviceAccountWorkspacePermissionsId: string;
}
export type DeleteServiceAccountWorkspacePermissionsRes = {
permissions: ServiceAccountWorkspacePermissions
}
+28 -5
View File
@@ -93,8 +93,8 @@ export const AppLayout = ({ children }: LayoutProps) => {
// Placing the localstorage as much as possible
// Wait till tony integrates the azure and its launched
useEffect(() => {
// Put a user in a workspace if they're not in one yet
const putUserInWorkSpace = async () => {
if (tempLocalStorage('orgData.id') === '') {
const userOrgs = await getOrganizations();
@@ -114,9 +114,33 @@ export const AppLayout = ({ children }: LayoutProps) => {
) {
router.push('/noprojects');
} else if (router.asPath !== '/noprojects') {
const intendedWorkspaceId = router.asPath
.split('/')
[router.asPath.split('/').length - 1].split('?')[0];
// const pathSegments = router.asPath.split('/').filter(segment => segment.length > 0);
// let intendedWorkspaceId;
// if (pathSegments.length >= 2 && pathSegments[0] === 'dashboard') {
// intendedWorkspaceId = pathSegments[1];
// } else if (pathSegments.length >= 3 && pathSegments[0] === 'settings') {
// intendedWorkspaceId = pathSegments[2];
// } else {
// intendedWorkspaceId = router.asPath
// .split('/')
// [router.asPath.split('/').length - 1].split('?')[0];
// }
const pathSegments = router.asPath.split('/').filter(segment => segment.length > 0);
let intendedWorkspaceId;
if (pathSegments.length >= 2 && pathSegments[0] === 'dashboard') {
[, intendedWorkspaceId] = pathSegments;
} else if (pathSegments.length >= 3 && pathSegments[0] === 'settings') {
[, , intendedWorkspaceId] = pathSegments;
} else {
const lastPathSegment = router.asPath.split('/').pop().split('?');
[intendedWorkspaceId] = lastPathSegment;
}
if (!intendedWorkspaceId) return;
if (!['callback', 'create', 'authorize'].includes(intendedWorkspaceId)) {
localStorage.setItem('projectData.id', intendedWorkspaceId);
@@ -192,7 +216,6 @@ export const AppLayout = ({ children }: LayoutProps) => {
});
if (addMembers) {
console.log('adding other users');
// not using hooks because need at this point only
const orgUsers = await fetchOrgUsers(currentOrg._id);
orgUsers.forEach(({ status, user: orgUser }) => {
@@ -0,0 +1,33 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import { getTranslatedServerSideProps } from '@app/components/utilities/withTranslateProps';
import { CreateServiceAccountPage } from '@app/views/Settings/CreateServiceAccountPage';
export default function ServiceAccountPage() {
const router = useRouter();
// const { orgId, serviceAccountId } = router.query;
const { t } = useTranslation();
return (
<>
<Head>
<title>Edit Service Account</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<div />
<CreateServiceAccountPage />
</>
);
}
ServiceAccountPage.requireAuth = true;
export const getServerSidePros = getTranslatedServerSideProps([
'settings',
'settings-org',
'section-incident',
'section-members'
]);
@@ -0,0 +1,20 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import Head from 'next/head';
export default function NewServiceAccountPage() {
console.log('NewServiceAccountPage');
return (
<div>
<Head>
<title>Some title</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<div>
Hello!
</div>
{/* <OrgSettingsPage /> */}
</div>
);
}
// NewServiceAccountPage.requireAuth = true;
@@ -0,0 +1,43 @@
import { useRouter } from 'next/router';
import NavHeader from '@app/components/navigation/NavHeader';
import { SAProjectLevelPermissionsTable } from './components/SAProjectLevelPermissionsTable';
import { ServiceAccountNameChangeSection } from './components';
export const CreateServiceAccountPage = () => {
const router = useRouter();
const { serviceAccountId }: { serviceAccountId: string } = router.query;
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<NavHeader
pageName="Service Account"
isOrganizationRelated
/>
<div className="my-8 ml-6 max-w-5xl">
<p className="text-3xl font-semibold text-gray-200">Service Account</p>
<p className="text-base font-normal text-gray-400">
A service account represents a machine identity such as a VM or application client.
</p>
</div>
<div className="max-w-8xl mx-6">
{typeof serviceAccountId === 'string' && (
<ServiceAccountNameChangeSection
serviceAccountId={serviceAccountId}
/>
)}
{/* <div className="rounded-md bg-white/5 p-6 mt-6">
<p className="mb-4 text-xl font-semibold">Organization-Level Permissions</p>
<SAProjectLevelPermissionsTable />
</div> */}
<div className="rounded-md bg-white/5 p-6 mt-6">
<p className="mb-4 text-xl font-semibold">Project-Level Permissions</p>
<SAProjectLevelPermissionsTable
serviceAccountId={serviceAccountId}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,394 @@
import { useEffect, useState } from 'react';
import { Controller,useForm } from 'react-hook-form';
import {
faKey,
faMagnifyingGlass,
faPlus,
faTrash} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import {
Button,
Checkbox,
DeleteActionModal,
EmptyState,
FormControl,
IconButton,
Input,
Modal,
ModalClose,
ModalContent,
Select,
SelectItem,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr} from '@app/components/v2';
import { usePopUp } from '@app/hooks';
import {
useCreateServiceAccountProjectLevelPermissions,
useDeleteServiceAccountProjectLevelPermissions,
useGetServiceAccountProjectLevelPermissions,
useGetUserWorkspaces} from '@app/hooks/api';
const createProjectLevelPermissionSchema = yup.object({
workspace: yup.string().required().label('Workspace'),
environment: yup.string().required().label('Environment'),
permissions: yup.object().shape({
canRead: yup.boolean().required(),
canWrite: yup.boolean().required(),
canUpdate: yup.boolean().required(),
canDelete: yup.boolean().required(),
}).defined().required()
});
type CreateProjectLevelPermissionForm = yup.InferType<typeof createProjectLevelPermissionSchema>;
type Props = {
serviceAccountId: string;
}
export const SAProjectLevelPermissionsTable = ({
serviceAccountId
}: Props) => {
const { data: userWorkspaces, isLoading: isUserWorkspacesLoading } = useGetUserWorkspaces();
const [searchPermissions, setSearchPermissions] = useState('');
const [defaultValues, setDefaultValues] = useState<CreateProjectLevelPermissionForm | undefined>(undefined);
const { data: permissions, isLoading: isPermissionsLoading } = useGetServiceAccountProjectLevelPermissions(serviceAccountId);
const createServiceAccountProjectLevelPermissions = useCreateServiceAccountProjectLevelPermissions();
const deleteServiceAccountProjectLevelPermissions = useDeleteServiceAccountProjectLevelPermissions();
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
'addProjectLevelPermissions',
'removeProjectLevelPermissions',
] as const);
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<CreateProjectLevelPermissionForm>({ resolver: yupResolver(createProjectLevelPermissionSchema), defaultValues })
const onAddProjectLevelPermissions = async ({
workspace,
environment,
permissions: { canRead, canWrite, canUpdate, canDelete }
}: CreateProjectLevelPermissionForm) => {
await createServiceAccountProjectLevelPermissions.mutateAsync({
serviceAccountId,
workspaceId: workspace,
environment,
canRead,
canWrite,
canUpdate,
canDelete
});
handlePopUpClose('addProjectLevelPermissions');
}
const onRemoveProjectLevelPermissions = async () => {
const serviceAccountWorkspacePermissionsId = (popUp?.removeProjectLevelPermissions?.data as { _id: string })?._id;
await deleteServiceAccountProjectLevelPermissions.mutateAsync({
serviceAccountId,
serviceAccountWorkspacePermissionsId
});
handlePopUpClose('removeProjectLevelPermissions');
}
useEffect(() => {
if (userWorkspaces) {
setDefaultValues({
workspace: String(userWorkspaces?.[0]?._id),
environment: String(userWorkspaces?.[0]?.environments?.[0]?.slug),
permissions: {
canRead: true,
canWrite: false,
canUpdate: false,
canDelete: false,
}
});
}
}, [userWorkspaces]);
return (
<div className="w-full">
<div className="mb-4 flex">
<div className="mr-4 flex-1">
<Input
value={searchPermissions}
onChange={(e) => setSearchPermissions(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search service account project-level permissions..."
/>
</div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
handlePopUpOpen('addProjectLevelPermissions')
reset();
}}
>
Add Permission
</Button>
</div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Project</Th>
<Th>Environment</Th>
<Th>Read</Th>
<Th>Write</Th>
<Th>Update</Th>
<Th>Delete</Th>
<Th aria-label="actions" />
</Tr>
</THead>
<TBody>
{isPermissionsLoading && <TableSkeleton columns={6} key="service-account-project-level-permissions" />}
{!isPermissionsLoading && permissions && (
permissions.map(({
_id,
workspace,
environment,
canRead,
canWrite,
canUpdate,
canDelete
}) => {
const environmentName = (workspace.environments.find((env) => env.slug === environment))?.name;
return (
<Tr key={`service-account-project-level-permission-${_id}`} className="w-full">
<Td>{workspace.name}</Td>
<Td>{environmentName}</Td>
<Td>
<Checkbox
id="isReadPermissionEnabled"
isChecked={canRead}
isDisabled
/>
</Td>
<Td>
<Checkbox
id="isWritePermissionEnabled"
isChecked={canWrite}
isDisabled
/>
</Td>
<Td>
<Checkbox
id="isUpdatePermissionEnabled"
isChecked={canUpdate}
isDisabled
/>
</Td>
<Td>
<Checkbox
id="isDeletePermissionEnabled"
isChecked={canDelete}
/>
</Td>
<Td>
<IconButton
ariaLabel="delete"
colorSchema="danger"
onClick={() => handlePopUpOpen('removeProjectLevelPermissions', { _id })}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Td>
</Tr>
);
})
)}
{!isPermissionsLoading && permissions?.length === 0 && (
<Tr>
<Td colSpan={7} className="py-6 text-center text-bunker-400">
<EmptyState title="No permissions found" icon={faKey} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
<Modal
isOpen={popUp?.addProjectLevelPermissions?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle('addProjectLevelPermissions', isOpen);
}}
>
<ModalContent
title="Add a Project-Level Permission"
subTitle="The service account will be granted scoped access to the specified project and environment"
>
<form onSubmit={handleSubmit(onAddProjectLevelPermissions)}>
{!isUserWorkspacesLoading && userWorkspaces && (
<>
<Controller
control={control}
name="workspace"
defaultValue={String(userWorkspaces?.[0]?._id)}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Project"
errorText={error?.message}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full border border-mine-shaft-500"
>
{userWorkspaces && userWorkspaces.length > 0 ? (
userWorkspaces.map((userWorkspace) => {
return (
<SelectItem value={userWorkspace._id} key={`project-${userWorkspace._id}`}>
{userWorkspace.name}
</SelectItem>
);
})
) : (
<SelectItem value="none" key="target-app-none">
No projects found
</SelectItem>
)}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="environment"
defaultValue={String(userWorkspaces?.[0]?.environments?.[0]?.slug)}
render={({ field: { onChange, ...field } }) => {
/* eslint-disable-next-line no-underscore-dangle */
const environments = userWorkspaces?.find((userWorkspace) => userWorkspace._id === control?._formValues?.workspace)?.environments ?? [];
return (
<FormControl
label="Environment"
className="mt-4"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full border border-mine-shaft-500"
>
{environments.length > 0 ? (
environments.map((environment) => {
return (
<SelectItem value={environment.slug} key={`environment-${environment.slug}`}>
{environment.name}
</SelectItem>
);
})
) : (
<SelectItem value="none" key="target-app-none">
No environments found
</SelectItem>
)}
</Select>
</FormControl>
);
}}
/>
</>
)}
<Controller
control={control}
name="permissions"
defaultValue={{
canRead: true,
canWrite: false,
canUpdate: false,
canDelete: false
}}
render={({ field: { onChange, value }, fieldState: { error }}) => {
const options = [
{
label: 'Read (default)',
value: 'canRead'
},
{
label: 'Write',
value: 'canWrite'
},
{
label: 'Update',
value: 'canUpdate'
},
{
label: 'Delete',
value: 'canDelete'
}
];
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>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.removeProjectLevelPermissions.isOpen}
deleteKey="remove"
title="Do you want to remove this permission from the service account?"
onChange={(isOpen) => handlePopUpToggle('removeProjectLevelPermissions', isOpen)}
onDeleteApproved={onRemoveProjectLevelPermissions}
/>
</div>
);
}
@@ -0,0 +1 @@
export { SAProjectLevelPermissionsTable } from './SAProjectLevelPermissionsTable';
@@ -0,0 +1,88 @@
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { faCheck } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import {
Button,
FormControl,
Input} from '@app/components/v2';
import {
useGetServiceAccountById,
useRenameServiceAccount
} from '@app/hooks/api';
const formSchema = yup.object({
name: yup.string().required().label('Service Account Name')
});
type FormData = yup.InferType<typeof formSchema>;
type Props = {
serviceAccountId: string;
}
export const ServiceAccountNameChangeSection = ({
serviceAccountId
}: Props) => {
const { data: serviceAccount, isLoading: isServiceAccountLoading } = useGetServiceAccountById(serviceAccountId);
const renameServiceAccount = useRenameServiceAccount();
const {
handleSubmit,
control,
reset,
formState: { isDirty, isSubmitting }
} = useForm<FormData>({ resolver: yupResolver(formSchema) });
useEffect(() => {
reset({ name: serviceAccount?.name });
}, [serviceAccount?.name]);
const onFormSubmit = async ({ name }: FormData) => {
try {
await renameServiceAccount.mutateAsync({
serviceAccountId,
name
});
} catch (err) {
console.error(err);
}
}
return (
<form
onSubmit={handleSubmit(onFormSubmit)}
className="rounded-md bg-white/5 p-6"
>
<p className="mb-4 text-xl font-semibold">Service Account Name</p>
<div className="mb-2 w-full max-w-lg">
{!isServiceAccountLoading && (
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input placeholder="Type your service account name..." {...field} />
</FormControl>
)}
control={control}
name="name"
/>
)}
</div>
<Button
isLoading={isSubmitting}
color="mineshaft"
size="sm"
type="submit"
isDisabled={!isDirty || isSubmitting}
leftIcon={<FontAwesomeIcon icon={faCheck} />}
>
Save Changes
</Button>
</form>
);
}
@@ -0,0 +1 @@
export { ServiceAccountNameChangeSection } from './ServiceAccountNameChangeSection';
@@ -0,0 +1,2 @@
export { SAProjectLevelPermissionsTable } from './SAProjectLevelPermissionsTable';
export { ServiceAccountNameChangeSection } from './ServiceAccountNameChangeSection';
@@ -0,0 +1 @@
export { CreateServiceAccountPage } from './CreateServiceAccountPage';
@@ -20,10 +20,15 @@ import {
useGetUserWsKey,
useRenameOrg,
useUpdateOrgUserRole,
useUploadWsKey
useUploadWsKey,
} from '@app/hooks/api';
import { OrgIncidentContactsTable, OrgMembersTable, OrgNameChangeSection } from './components';
import {
OrgIncidentContactsTable,
OrgMembersTable,
OrgNameChangeSection,
OrgServiceAccountsTable
} from './components';
export const OrgSettingsPage = () => {
const host = window.location.origin;
@@ -36,12 +41,11 @@ export const OrgSettingsPage = () => {
const { createNotification } = useNotificationContext();
const orgId = currentOrg?._id || '';
const { data: orgUsers, isLoading: isOrgUserLoading } = useGetOrgUsers(orgId);
const { data: workspaceMemberships, isLoading: IsWsMembershipLoading } =
useGetUserWorkspaceMemberships(orgId);
const { data: workspaceMemberships, isLoading: IsWsMembershipLoading } = useGetUserWorkspaceMemberships(orgId);
const { data: wsKey } = useGetUserWsKey(currentWorkspace?._id || '');
const { data: incidentContact, isLoading: IsIncidentContactLoading } =
useGetOrgIncidentContact(orgId);
const { data: incidentContact, isLoading: IsIncidentContactLoading } = useGetOrgIncidentContact(orgId);
const renameOrg = useRenameOrg();
const removeUserOrgMembership = useDeleteOrgMembership();
@@ -222,17 +226,15 @@ export const OrgSettingsPage = () => {
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<NavHeader pageName={t('settings-org:title')} />
<div className="my-8 ml-6 flex max-w-5xl flex-row items-center justify-between text-xl">
<div className="flex flex-col items-start justify-start text-3xl">
<p className="mr-4 font-semibold text-gray-200">{t('settings-org:title')}</p>
<p className="mr-4 text-base font-normal text-gray-400">
{t('settings-org:description')}
</p>
</div>
<div className="my-8 max-w-5xl ml-8">
<p className="text-3xl font-semibold text-gray-200">{t('settings-org:title')}</p>
<p className="text-base font-normal text-gray-400">
{t('settings-org:description')}
</p>
</div>
<div className="max-w-8xl ml-6 mr-6 flex flex-col text-mineshaft-50">
<OrgNameChangeSection orgName={currentOrg?.name} onOrgNameChange={onRenameOrg} />
<div className="mb-6 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pt-6 pb-6">
<div className="w-full rounded-md bg-white/5 p-6 mb-6">
<p className="mr-4 mb-4 text-xl font-semibold text-white">
{t('section-members:org-members')}
</p>
@@ -249,6 +251,12 @@ export const OrgSettingsPage = () => {
onGrantAccess={onGrantUserAccess}
/>
</div>
<div className="mb-6 mt-2 w-full rounded-md bg-white/5 p-6">
<p className="mr-4 mb-4 text-xl font-semibold text-white">
Service Accounts
</p>
<OrgServiceAccountsTable />
</div>
<div className="mb-6 mt-2 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pt-6 pb-6">
<div className="flex w-full max-w-5xl flex-row items-center justify-between">
<div className="flex w-full max-w-3xl flex-col justify-between">
@@ -26,7 +26,8 @@ import {
Td,
Th,
THead,
Tr} from '@app/components/v2';
Tr
} from '@app/components/v2';
import { usePopUp } from '@app/hooks';
import { IncidentContact } from '@app/hooks/api/types';
@@ -93,7 +93,7 @@ export const OrgMembersTable = ({
() => members.find(({ user }) => userId === user?._id)?.role === 'owner',
[userId, members]
);
const filterdUser = useMemo(
() =>
members.filter(
@@ -117,20 +117,18 @@ export const OrgMembersTable = ({
placeholder="Search members..."
/>
</div>
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
if (isMoreUserNotAllowed) {
handlePopUpOpen('upgradePlan');
} else {
handlePopUpOpen('addMember');
}
}}
>
Add Member
</Button>
</div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
if (isMoreUserNotAllowed) {
handlePopUpOpen('upgradePlan');
} else {
handlePopUpOpen('addMember');
}
}}
>
Add Member
</Button>
</div>
<div>
<TableContainer>
@@ -0,0 +1,353 @@
import { useEffect, useMemo,useState } from 'react';
import { Controller,useForm } from 'react-hook-form';
import { useRouter } from 'next/router';
import {
faCheck,
faCopy,
faMagnifyingGlass,
faPencil,
faPlus,
faServer,
faTrash} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import { generateKeyPair } from '@app/components/utilities/cryptography/crypto';
import {
Button,
DeleteActionModal,
EmptyState,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
Select,
SelectItem,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from '@app/components/v2';
import { useOrganization, useWorkspace } from '@app/context';
import { usePopUp, useToggle } from '@app/hooks';
import {
useCreateServiceAccount,
useDeleteServiceAccount,
useGetServiceAccounts} from '@app/hooks/api';
const serviceAccountExpiration = [
{ 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: -1 }
];
const addServiceAccountFormSchema = yup.object({
name: yup.string().required().label('Name').trim(),
expiresIn: yup.string().required().label('Service Account Expiration')
});
type TAddServiceAccountForm = yup.InferType<typeof addServiceAccountFormSchema>;
export const OrgServiceAccountsTable = () => {
const router = useRouter();
const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace();
console.log('currentWorkspace: ', currentWorkspace);
const orgId = currentOrg?._id || '';
const [step, setStep] = useState(0);
const [isAccessKeyCopied, setIsAccessKeyCopied] = useToggle(false);
const [isPrivateKeyCopied, setIsPrivateKeyCopied] = useToggle(false);
const [accessKey, setAccessKey] = useState('');
const [privateKey, setPrivateKey] = useState('');
const [searchServiceAccountFilter, setSearchServiceAccountFilter] = useState('');
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
'addServiceAccount',
'removeServiceAccount',
] as const);
const { data: serviceAccounts = [], isLoading: isServiceAccountsLoading } = useGetServiceAccounts(orgId);
const createServiceAccount = useCreateServiceAccount();
const removeServiceAccount = useDeleteServiceAccount();
useEffect(() => {
let timer: NodeJS.Timeout;
if (isAccessKeyCopied) {
timer = setTimeout(() => setIsAccessKeyCopied.off(), 2000);
}
if (isPrivateKeyCopied) {
timer = setTimeout(() => setIsPrivateKeyCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isAccessKeyCopied, isPrivateKeyCopied]);
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<TAddServiceAccountForm>({ resolver: yupResolver(addServiceAccountFormSchema) });
const onAddServiceAccount = async ({ name, expiresIn }: TAddServiceAccountForm) => {
if (!currentOrg?._id) return;
const keyPair = generateKeyPair();
setPrivateKey(keyPair.privateKey);
const serviceAccountDetails = await createServiceAccount.mutateAsync({
name,
organizationId: currentOrg?._id,
publicKey: keyPair.publicKey,
expiresIn
});
console.log('serviceAccountDetails: ', serviceAccountDetails);
setAccessKey(serviceAccountDetails.serviceAccountAccessKey);
setStep(1);
reset();
}
const onRemoveServiceAccount = async () => {
console.log('onRemoveServiceAccount');
const serviceAccountId = (popUp?.removeServiceAccount?.data as { _id: string })?._id;
console.log('serviceAccountId: ', serviceAccountId);
await removeServiceAccount.mutateAsync(serviceAccountId);
handlePopUpClose('removeServiceAccount');
}
const filteredServiceAccounts = useMemo(
() =>
serviceAccounts.filter(
({ name }) =>
name.toLowerCase().includes(searchServiceAccountFilter)
),
[serviceAccounts, searchServiceAccountFilter]
);
const renderStep = (stepToRender: number) => {
switch (stepToRender) {
case 0:
return (
<form onSubmit={handleSubmit(onAddServiceAccount)}>
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl label="Name" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="expiresIn"
defaultValue={String(serviceAccountExpiration?.[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"
>
{serviceAccountExpiration.map(({ label, value }) => (
<SelectItem value={String(value)} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create Service Account
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose('addServiceAccount')}
>
Cancel
</Button>
</div>
</form>
);
case 1:
return (
<>
<p>Access Key</p>
<div className="flex items-center justify-end rounded-md p-2 text-base text-gray-400 bg-white/[0.07]">
<p className="mr-4 break-all">{accessKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(accessKey);
setIsAccessKeyCopied.on();
}}
>
<FontAwesomeIcon icon={isAccessKeyCopied ? 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">
Copy
</span>
</IconButton>
</div>
<p className="mt-4">Private Key</p>
<div className="flex items-center justify-end rounded-md p-2 text-base text-gray-400 bg-white/[0.07]">
<p className="mr-4 break-all">{privateKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(privateKey);
setIsPrivateKeyCopied.on();
}}
>
<FontAwesomeIcon icon={isPrivateKeyCopied ? 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">
Copy
</span>
</IconButton>
</div>
</>
);
default:
return <div />
}
}
console.log('serviceAccounts: ', serviceAccounts);
return (
<div className="w-full">
<div className="mb-4 flex">
<div className="mr-4 flex-1">
<Input
value={searchServiceAccountFilter}
onChange={(e) => setSearchServiceAccountFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search service accounts..."
/>
</div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
setStep(0);
reset();
handlePopUpOpen('addServiceAccount');
}}
>
Add Service Account
</Button>
</div>
<TableContainer>
<Table>
<THead>
<Th>Name</Th>
<Th className="w-full">Valid Until</Th>
<Th aria-label="actions" />
</THead>
<TBody>
{isServiceAccountsLoading && <TableSkeleton columns={5} key="org-service-accounts" />}
{!isServiceAccountsLoading && (
filteredServiceAccounts.map(({
name,
expiresAt,
_id: serviceAccountId
}) => {
return (
<Tr key={`org-service-account-${serviceAccountId}`}>
<Td>{name}</Td>
<Td>{new Date(expiresAt).toUTCString()}</Td>
<Td>
<div className="flex">
<IconButton
ariaLabel="edit"
colorSchema="secondary"
onClick={() => {
if (currentWorkspace?._id) {
router.push(`/settings/org/${currentWorkspace._id}/service-accounts/${serviceAccountId}`);
}
}}
className="mr-2"
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
<IconButton
ariaLabel="delete"
colorSchema="danger"
onClick={() => handlePopUpOpen('removeServiceAccount', { _id: serviceAccountId })}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
</Td>
</Tr>
);
})
)}
{!isServiceAccountsLoading && filteredServiceAccounts?.length === 0 && (
<Tr>
<Td colSpan={4} className="text-center">
<EmptyState title="No service accounts found" icon={faServer} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
<Modal
isOpen={popUp?.addServiceAccount?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle('addServiceAccount', isOpen);
reset();
}}
>
<ModalContent
title="Add Service Account"
subTitle="A service account represents a machine identity such as a VM or application client."
>
{renderStep(step)}
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.removeServiceAccount.isOpen}
deleteKey="remove"
title="Do you want to remove this service account from the org?"
onChange={(isOpen) => handlePopUpToggle('removeServiceAccount', isOpen)}
onDeleteApproved={onRemoveServiceAccount}
/>
</div>
);
}
@@ -0,0 +1 @@
export { OrgServiceAccountsTable } from './OrgServiceAccountsTable';
@@ -1,3 +1,5 @@
export { OrgIncidentContactsTable } from './OrgIncidentContactsTable';
export { OrgMembersTable } from './OrgMembersTable';
export { OrgNameChangeSection } from './OrgNameChangeSection';
export { OrgServiceAccountsTable } from './OrgServiceAccountsTable';
@@ -43,7 +43,7 @@ const apiTokenExpiry = [
const createServiceTokenSchema = yup.object({
name: yup.string().required().label('Service Token Name'),
environment: yup.string().required().label('Environment'),
expiresIn: yup.string().required().label('Service Token Name'),
expiresIn: yup.string().required().label('Service Token Expiration'),
permissions: yup.object().shape({
read: yup.boolean().required(),
write: yup.boolean().required()
@@ -202,7 +202,7 @@ export const ServiceTokenSection = ({
defaultValue={String(apiTokenExpiry?.[0]?.value)}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Token Expiry"
label="Expiration"
errorText={error?.message}
isError={Boolean(error)}
>
@@ -269,42 +269,6 @@ export const ServiceTokenSection = ({
);
}}
/>
{/* <Controller
name="isReadEnabled"
defaultValue={true}
control={control}
render={({ field: { onChange, ... field }, fieldState }) => {
return (
<Checkbox
className="data-[state=checked]:bg-primary"
isChecked={field.value}
onCheckedChange={(state) => {
onChange(state);
}}
>
Read (default)
</Checkbox>
);
}}
/>
<Controller
name="isWriteEnabled"
defaultValue={false}
control={control}
render={({ field: { onChange, ... field }, fieldState }) => {
return (
<Checkbox
className="data-[state=checked]:bg-primary"
isChecked={field.value}
onCheckedChange={(state) => {
onChange(state);
}}
>
Write (optional)
</Checkbox>
);
}}
/> */}
<div className="mt-8 flex items-center">
<Button
className="mr-4"