diff --git a/.env.example b/.env.example index 79fca5686..df316753e 100644 --- a/.env.example +++ b/.env.example @@ -31,12 +31,12 @@ MONGO_PASSWORD=example SITE_URL=http://localhost:8080 # Mail/SMTP -SMTP_HOST= # required -SMTP_USERNAME= # required -SMTP_PASSWORD= # required +SMTP_HOST= +SMTP_USERNAME= +SMTP_PASSWORD= SMTP_PORT=587 SMTP_SECURE=false -SMTP_FROM_ADDRESS= # required +SMTP_FROM_ADDRESS= SMTP_FROM_NAME=Infisical # Integration @@ -66,4 +66,4 @@ STRIPE_WEBHOOK_SECRET= STRIPE_PRODUCT_STARTER= STRIPE_PRODUCT_TEAM= STRIPE_PRODUCT_PRO= -NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= \ No newline at end of file +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= diff --git a/README.md b/README.md index 1e3b977b1..32a3ce732 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ git commit activity - Cloudsmith downloads + Cloudsmith downloads Slack community channel diff --git a/backend/src/controllers/v2/serviceTokenDataController.ts b/backend/src/controllers/v2/serviceTokenDataController.ts index 6b3d24dfb..328749472 100644 --- a/backend/src/controllers/v2/serviceTokenDataController.ts +++ b/backend/src/controllers/v2/serviceTokenDataController.ts @@ -75,8 +75,11 @@ export const createServiceTokenData = async (req: Request, res: Response) => { const secret = crypto.randomBytes(16).toString('hex'); const secretHash = await bcrypt.hash(secret, getSaltRounds()); - const expiresAt = new Date(); - expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); + let expiresAt; + if (!!expiresIn) { + expiresAt = new Date() + expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); + } let user, serviceAccount; diff --git a/frontend/src/components/analytics/posthog.ts b/frontend/src/components/analytics/posthog.ts index 29208a795..b6f3e4341 100644 --- a/frontend/src/components/analytics/posthog.ts +++ b/frontend/src/components/analytics/posthog.ts @@ -6,7 +6,7 @@ import { ENV, POSTHOG_API_KEY, POSTHOG_HOST } from '../utilities/config'; export const initPostHog = () => { // @ts-ignore - console.log("Init Infisical") + console.log("Hi there 👋") try { if (typeof window !== 'undefined') { // @ts-ignore diff --git a/frontend/src/components/navigation/NavHeader.tsx b/frontend/src/components/navigation/NavHeader.tsx index e1ee09080..c8af1ce46 100644 --- a/frontend/src/components/navigation/NavHeader.tsx +++ b/frontend/src/components/navigation/NavHeader.tsx @@ -1,52 +1,89 @@ +import { useRouter } from 'next/router'; import { faAngleRight } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { useOrganization, useWorkspace } from '@app/context'; +import { Select, SelectItem, Tooltip } from '../v2'; + // 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 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) + * @param {object} obj + * @param {string} obj.pageName - Name of the page + * @param {boolean} obj.isProjectRelated - whether or not this page is related to project (determine if it's 2 or 3 navigation steps) + * @param {boolean} obj.isOrganizationRelated - whether or not this page is related to organization (determine if it's 2 or 3 navigation steps) + * @param {string} obj.currentEnv - current environment inside a project + * @param {string} obj.userAvailableEnvs - environments that are available to a user in this project (used for the dropdown) + * @param {string} obj.onEnvChange - the action that happens when an env is changed * @returns */ export default function NavHeader({ pageName, isProjectRelated, - isOrganizationRelated + isOrganizationRelated, + currentEnv, + userAvailableEnvs, + onEnvChange }: { pageName: string; isProjectRelated?: boolean; isOrganizationRelated?: boolean; + currentEnv?: string; + userAvailableEnvs?: any[]; + onEnvChange?: (slug: string) => void; }): JSX.Element { const { currentWorkspace } = useWorkspace(); const { currentOrg } = useOrganization(); + const router = useRouter() return (
{currentOrg?.name?.charAt(0)}
-
{currentOrg?.name}
+
{currentOrg?.name}
{isProjectRelated && ( <> - -
{currentWorkspace?.name}
+ +
{currentWorkspace?.name}
)} {isOrganizationRelated && ( <> - -
Organization Settings
+ +
Organization Settings
)} -
{pageName}
+ {pageName === 'Secrets' + ?
{pageName} + :
{pageName}
} + {currentEnv && + <> + +
+ + + +
+ }
); } diff --git a/frontend/src/components/v2/Button/Button.tsx b/frontend/src/components/v2/Button/Button.tsx index c4a89023c..17ae7b867 100644 --- a/frontend/src/components/v2/Button/Button.tsx +++ b/frontend/src/components/v2/Button/Button.tsx @@ -34,6 +34,7 @@ const buttonVariants = cva( outline: ['bg-transparent', 'border-2', 'border-solid'], plain: '', selected: '', + outline_bg: '', // a constant color not in use on hover or click goes colorSchema color star: 'text-bunker-200 bg-mineshaft-500' }, @@ -67,6 +68,11 @@ const buttonVariants = cva( variant: 'selected', className: 'bg-primary/10 border border-primary/50 text-bunker-200' }, + { + colorSchema: 'primary', + variant: 'outline_bg', + className: 'bg-mineshaft-800 border border-mineshaft-600 hover:bg-primary/[0.15] hover:border-primary/60 text-bunker-200' + }, { colorSchema: 'secondary', variant: 'star', diff --git a/frontend/src/components/v2/Select/Select.tsx b/frontend/src/components/v2/Select/Select.tsx index ffeaf3c3f..46cd3fb03 100644 --- a/frontend/src/components/v2/Select/Select.tsx +++ b/frontend/src/components/v2/Select/Select.tsx @@ -1,6 +1,6 @@ import { forwardRef, ReactNode } from 'react'; import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { faCheck, faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'; +import { faCaretDown, faCheck, faChevronUp } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import * as SelectPrimitive from '@radix-ui/react-select'; import { twMerge } from 'tailwind-merge'; @@ -49,7 +49,7 @@ export const Select = forwardRef( {!isDisabled && ( - + )} @@ -76,7 +76,7 @@ export const Select = forwardRef( )} - + diff --git a/frontend/src/ee/components/SecretVersionList.tsx b/frontend/src/ee/components/SecretVersionList.tsx index 3f16a228d..dbf7c5bf2 100644 --- a/frontend/src/ee/components/SecretVersionList.tsx +++ b/frontend/src/ee/components/SecretVersionList.tsx @@ -76,7 +76,7 @@ const SecretVersionList = ({ secretId }: { secretId: string }) => { }, [secretId]); return ( -
+

{t('dashboard:sidebar.version-history')}

{isLoading ? ( @@ -102,7 +102,7 @@ const SecretVersionList = ({ secretId }: { secretId: string }) => {
-
+
{new Date(version.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: '2-digit', diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index 9b9c49df5..031c661d1 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -19,18 +19,42 @@ import { export const secretKeys = { // this is also used in secretSnapshot part - getProjectSecret: (workspaceId: string, env: string) => [{ workspaceId, env }, 'secrets'], + getProjectSecret: (workspaceId: string, env: string | string[]) => [{ workspaceId, env }, 'secrets'], getSecretVersion: (secretId: string) => [{ secretId }, 'secret-versions'] }; -const fetchProjectEncryptedSecrets = async (workspaceId: string, env: string) => { - const { data } = await apiRequest.get<{ secrets: EncryptedSecret[] }>('/api/v2/secrets', { - params: { - environment: env, - workspaceId +const fetchProjectEncryptedSecrets = async (workspaceId: string, env: string | string[]) => { + if (typeof env === 'string') { + const { data } = await apiRequest.get<{ secrets: EncryptedSecret[] }>('/api/v2/secrets', { + params: { + environment: env, + workspaceId + } + }); + return data.secrets; + } + + if (typeof env === 'object') { + let allEnvData: any = []; + + // eslint-disable-next-line no-restricted-syntax + for (const envPoint of env) { + // eslint-disable-next-line no-await-in-loop + const { data } = await apiRequest.get<{ secrets: EncryptedSecret[] }>('/api/v2/secrets', { + params: { + environment: envPoint, + workspaceId + } + }); + allEnvData = allEnvData.concat(data.secrets); } - }); - return data.secrets; + + return allEnvData; + // eslint-disable-next-line no-else-return + } else { + return null; + } + }; export const useGetProjectSecrets = ({ @@ -59,7 +83,7 @@ export const useGetProjectSecrets = ({ // this used for add-only mode in dashboard // type won't be there thus only one key is shown const duplicateSecretKey: Record = {}; - data.forEach((encSecret) => { + data.forEach((encSecret: EncryptedSecret) => { const secretKey = decryptSymmetric({ ciphertext: encSecret.secretKeyCiphertext, iv: encSecret.secretKeyIV, @@ -93,12 +117,12 @@ export const useGetProjectSecrets = ({ }; if (encSecret.type === 'personal') { - personalSecrets[decryptedSecret.key] = { id: encSecret._id, value: secretValue }; + personalSecrets[`${decryptedSecret.key}-${decryptedSecret.env}`] = { id: encSecret._id, value: secretValue }; } else { - if (!duplicateSecretKey?.[decryptedSecret.key]) { + if (!duplicateSecretKey?.[`${decryptedSecret.key}-${decryptedSecret.env}`]) { sharedSecrets.push(decryptedSecret); } - duplicateSecretKey[decryptedSecret.key] = true; + duplicateSecretKey[`${decryptedSecret.key}-${decryptedSecret.env}`] = true; } }); sharedSecrets.forEach((val) => { diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index 7888e64b3..567fecc9a 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -90,7 +90,7 @@ export type BatchSecretDTO = { export type GetProjectSecretsDTO = { workspaceId: string; - env: string; + env: string | string[]; decryptFileKey: UserWsKeyPair; isPaused?: boolean; onSuccess?: (data: DecryptedSecret[]) => void; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 93ed5b860..85828c3c5 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -20,6 +20,7 @@ import { } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { yupResolver } from '@hookform/resolvers/yup'; +import queryString from 'query-string'; import * as yup from 'yup'; import { useNotificationContext } from '@app/components/context/Notifications/NotificationProvider'; @@ -158,7 +159,10 @@ export const AppLayout = ({ children }: LayoutProps) => { .map((workspace: { _id: string }) => workspace._id) .includes(intendedWorkspaceId) ) { - router.push(`/dashboard/${userWorkspaces[0]._id}`); + const { env } = queryString.parse(router.asPath.split('?')[1]); + if (!env) { + router.push(`/dashboard/${userWorkspaces[0]._id}`); + } } else { setWorkspaceMapping( Object.fromEntries( @@ -271,11 +275,12 @@ export const AppLayout = ({ children }: LayoutProps) => { {name} ))} -
+ {/*
*/}
@@ -960,20 +968,7 @@ export default function Dashboard() {
{(snapshotData || data?.length !== 0) && selectedEnv && ( <> - {!snapshotData ? ( - name)} - onChange={handleOnEnvironmentChange} - /> - ) : ( - name)} - onChange={handleOnEnvironmentChange} - /> - )} -
+
infisical loading indicator @@ -1223,10 +1218,10 @@ export default function Dashboard() {
) : ( -
+
loading animation
- ); + ))}
} Dashboard.requireAuth = true; diff --git a/frontend/src/views/DashboardPage/DashboardEnvOverview.tsx b/frontend/src/views/DashboardPage/DashboardEnvOverview.tsx new file mode 100644 index 000000000..a7545fb1c --- /dev/null +++ b/frontend/src/views/DashboardPage/DashboardEnvOverview.tsx @@ -0,0 +1,275 @@ +import { useEffect, useState } from 'react'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { useRouter } from 'next/router'; +import { yupResolver } from '@hookform/resolvers/yup'; + +import { useNotificationContext } from '@app/components/context/Notifications/NotificationProvider'; +import NavHeader from '@app/components/navigation/NavHeader'; +import { + Button, + Modal, + ModalContent, + TableContainer, + Tooltip +} from '@app/components/v2'; +import { useWorkspace } from '@app/context'; +import { usePopUp } from '@app/hooks'; +import { + useCreateWsTag, + useGetProjectSecrets, + useGetUserWsEnvironments, + useGetUserWsKey, +} from '@app/hooks/api'; +import { WorkspaceEnv } from '@app/hooks/api/types'; + +import { CreateTagModal } from './components/CreateTagModal'; +import { EnvComparisonRow } from './components/EnvComparisonRow'; +import { + FormData, + schema +} from './DashboardPage.utils'; + + +export const DashboardEnvOverview = () => { + const { t } = useTranslation(); + const router = useRouter(); + const { createNotification } = useNotificationContext(); + + const { popUp + // , handlePopUpOpen + , handlePopUpToggle, handlePopUpClose } = usePopUp([ + 'secretDetails', + 'addTag', + 'secretSnapshots', + 'uploadedSecOpts', + 'compareSecrets' + ] as const); + const [selectedEnv, setSelectedEnv] = useState(null); + + const { currentWorkspace, isLoading } = useWorkspace(); + const workspaceId = currentWorkspace?._id as string; + const { data: latestFileKey } = useGetUserWsKey(workspaceId); + + useEffect(() => { + if (!isLoading && !workspaceId && router.isReady) { + router.push('/noprojects'); + } + }, [isLoading, workspaceId, router.isReady]); + + const { data: wsEnv, isLoading: isEnvListLoading } = useGetUserWsEnvironments({ + workspaceId, + onSuccess: (data) => { + // get an env with one of the access available + const env = data.find(({ isReadDenied }) => !isReadDenied); + if (env) { + setSelectedEnv(env); + } + } + }); + + const userAvailableEnvs = wsEnv?.filter( + ({ isReadDenied }) => !isReadDenied + ); + + const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({ + workspaceId, + env: userAvailableEnvs?.map(env => env.slug) ?? [], + decryptFileKey: latestFileKey!, + isPaused: false + }); + + // mutation calls + const { mutateAsync: createWsTag } = useCreateWsTag(); + + const method = useForm({ + // why any: well yup inferred ts expects other keys to defined as undefined + defaultValues: secrets as any, + values: secrets as any, + mode: 'onBlur', + resolver: yupResolver(schema) + }); + + const { + control, + // handleSubmit, + // getValues, + // setValue, + // formState: { isSubmitting, dirtyFields }, + // reset + } = method; + const formSecrets = useWatch({ control, name: 'secrets' }); + + const isReadOnly = selectedEnv?.isWriteDenied; + + const onCreateWsTag = async (tagName: string) => { + try { + await createWsTag({ + workspaceID: workspaceId, + tagName, + tagSlug: tagName.replace(' ', '_') + }); + handlePopUpClose('addTag'); + createNotification({ + text: 'Successfully created a tag', + type: 'success' + }); + } catch (error) { + console.error(error); + createNotification({ + text: 'Failed to create a tag', + type: 'error' + }); + } + }; + + if (isSecretsLoading || isEnvListLoading) { + return ( +
+ loading animation +
+ ); + } + + // when secrets is not loading and secrets list is empty + const isDashboardSecretEmpty = !isSecretsLoading && !formSecrets?.length; + + const numSecretsMissingPerEnv = userAvailableEnvs?.map(envir => ({[envir.slug]: [... new Set(secrets?.secrets.map((secret: any) => secret.key))].length - [... new Set(secrets?.secrets.filter(s => s.env === envir.slug).map((secret: any) => secret.key))].length})).reduce((acc, cur) => ({ ...acc, ...cur }), {}) + + return ( +
+ +
+ {/* breadcrumb row */} +
+ +
+
+

Secrets Overview

+

Inject your secrets using + + Infisical CLI + + or + + Infisical SDKs +

+
+
+
+
+
{0}
+
+
+
+
Secret
+
+
+ {numSecretsMissingPerEnv && userAvailableEnvs?.map(env => { + return
+
+ {env.name} + {numSecretsMissingPerEnv[env.slug] > 0 &&
+ {numSecretsMissingPerEnv[env.slug]} +
} +
+
+ })} +
+
+ {!isDashboardSecretEmpty && ( + + + + {[... new Set(secrets?.secrets.map((secret: any) => secret.key))].map((key, index) => ( + secret.key === key)} + isReadOnly={isReadOnly} + index={index} + isSecretValueHidden + userAvailableEnvs={userAvailableEnvs} + /> + ))} + +
+
+ )} + {isDashboardSecretEmpty && +
+
+
{0}
+
+
+
+
Secret
+
+
+
+ No secrets are available in this project yet. + You can go into any environment to add secrets there. +
+
} + {/* In future, we should add an option to add environments here +
+ +
*/} +
+
+
0
+
+ 0 + +
+ {userAvailableEnvs?.map(env => { + return
+ +
+ })} +
+
+
+ { + handlePopUpToggle('addTag', open); + }} + > + + + + +
+
+ ); +}; diff --git a/frontend/src/views/DashboardPage/DashboardPage.tsx b/frontend/src/views/DashboardPage/DashboardPage.tsx index 146b42165..75a42c0df 100644 --- a/frontend/src/views/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/DashboardPage/DashboardPage.tsx @@ -28,8 +28,6 @@ import { Popover, PopoverContent, PopoverTrigger, - Select, - SelectItem, TableContainer, Tag, Tooltip @@ -86,7 +84,7 @@ const USER_ACTION_PUSH = 'first_time_secrets_pushed'; * Instead when user delete we raise a flag so if user decides to go back to toggle personal before saving * They will get it back */ -export const DashboardPage = () => { +export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { const { t } = useTranslation(); const router = useRouter(); const { createNotification } = useNotificationContext(); @@ -126,8 +124,8 @@ export const DashboardPage = () => { onSuccess: (data) => { // get an env with one of the access available const env = data.find(({ isReadDenied, isWriteDenied }) => !isWriteDenied || !isReadDenied); - if (env) { - setSelectedEnv(env); + if (env && data?.map(wsenv => wsenv.slug).includes(envFromTop)) { + setSelectedEnv(data?.filter(dp => dp.slug === envFromTop)[0]); } } }); @@ -357,6 +355,7 @@ export const DashboardPage = () => { } const env = wsEnv?.find((el) => el.slug === slug); if (env) setSelectedEnv(env); + router.push(`${router.asPath.split("?")[0]}?env=${slug}`) }; // record all deleted ids @@ -417,7 +416,13 @@ export const DashboardPage = () => {
{/* breadcrumb row */}
- + envir.slug === envFromTop)[0].name || ''} + isProjectRelated + userAvailableEnvs={userAvailableEnvs} + onEnvChange={onEnvChange} + />
{/* Secrets, commit and save button section */}
@@ -468,23 +473,6 @@ export const DashboardPage = () => {
{/* Environment, search and other action row */}
-
- - - -
{ + const ref = useRef(null); + const syncScroll = (e: SyntheticEvent) => { + if (ref.current === null) return; + + ref.current.scrollTop = e.currentTarget.scrollTop; + ref.current.scrollLeft = e.currentTarget.scrollLeft; + }; + + return +
+ +
+ {(secret?.value || secret?.value === '') && (isOverridden ? secret.valueOverride : secret?.value)?.split('').length === 0 && EMPTY} + {(secret?.value || secret?.value === '') && (isOverridden ? secret.valueOverride : secret?.value)?.split(REGEX).map((word: string) => { + if (word.match(REGEX) !== null) { + return ( + + {word.slice(0, 2)} + + {word.slice(2, word.length - 1)} + + {word.slice(word.length - 1, word.length) === '}' ? ( + + {word.slice(word.length - 1, word.length)} + + ) : ( + + {word.slice(word.length - 1, word.length)} + + )} + + ); + } + return ( + + {word} + + ); + })} + {!(secret?.value || secret?.value === '') && missing} +
+ {(isSecretValueHidden && secret?.value) && ( +
+
+ {(isOverridden ? secret.valueOverride : secret?.value || '')?.split('').map(() => ( + + ))} + {(isOverridden ? secret.valueOverride : secret?.value || '')?.split('').length === 0 && EMPTY} +
+
+ )} +
+ +} + +export const EnvComparisonRow = ({ + index, + secrets, + isSecretValueHidden, + isReadOnly, + userAvailableEnvs +}: Props): JSX.Element => { + const { + // register, setValue, + control } = useFormContext(); + + // to get details on a secret + const secret = useWatch({ name: `secrets.${index}`, control }); + + const [areValuesHiddenThisRow, setAreValuesHiddenThisRow] = useState(true); + + return ( + +
{index + 1}
+ +
{secret?.key || ''}
+ + + {userAvailableEnvs?.map(env => { + return sec.env === env.slug)[0]} index={index} /> + })} + + ); +}; diff --git a/frontend/src/views/DashboardPage/components/EnvComparisonRow/index.tsx b/frontend/src/views/DashboardPage/components/EnvComparisonRow/index.tsx new file mode 100644 index 000000000..e0c9f8847 --- /dev/null +++ b/frontend/src/views/DashboardPage/components/EnvComparisonRow/index.tsx @@ -0,0 +1 @@ +export { EnvComparisonRow } from './EnvComparisonRow'; diff --git a/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx b/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx index 850e8866c..f0b2cd092 100644 --- a/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx +++ b/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx @@ -156,7 +156,7 @@ export const SecretInputRow = ({ } return ( - +
{index + 1}
{ if (word.match(REGEX) !== null) { return ( - + {word.slice(0, 2)} {word.slice(2, word.length - 1)} diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx index 98019e0ad..cbfc1aeed 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx @@ -64,7 +64,6 @@ export const SecretTagsSection = ({ }); const onFormSubmit = async (data: CreateWsTag) => { - console.log(19191, data); await onCreateTag(data); handlePopUpClose('CreateSecretTag'); }; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx index b857a3224..10c95ee55 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx @@ -37,13 +37,14 @@ const apiTokenExpiry = [ { label: '7 Days', value: 604800 }, { label: '1 Month', value: 2592000 }, { label: '6 months', value: 15552000 }, - { label: '12 months', value: 31104000 } + { label: '12 months', value: 31104000 }, + { label: 'Never', value: null }, ]; 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 Expiration'), + expiresIn: yup.string().optional().label('Service Token Expiration'), permissions: yup.object().shape({ read: yup.boolean().required(), write: yup.boolean().required() @@ -213,7 +214,7 @@ export const ServiceTokenSection = ({ className="w-full" > {apiTokenExpiry.map(({ label, value }) => ( - + {label} ))} @@ -332,7 +333,7 @@ export const ServiceTokenSection = ({ {row.name} {row.environment} - {new Date(row.expiresAt).toUTCString()} + {row.expiresAt && new Date(row.expiresAt).toUTCString()}