@@ -64,7 +78,7 @@ const ActivityLogsRow = ({
)
.join(' and ')}
- {row.user}
+ {renderUser()}
{row.channel}
{timeSince(new Date(row.createdAt))}
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/index.tsx b/frontend/src/hooks/api/index.tsx
index c2035bbba..89d947068 100644
--- a/frontend/src/hooks/api/index.tsx
+++ b/frontend/src/hooks/api/index.tsx
@@ -2,6 +2,9 @@ export * from './auth';
export * from './incidentContacts';
export * from './keys';
export * from './organization';
+export * from './secrets';
+export * from './secretSnapshots';
+export * from './serviceAccounts';
export * from './serviceTokens';
export * from './subscriptions';
export * from './tags';
diff --git a/frontend/src/hooks/api/integrationAuth/index.tsx b/frontend/src/hooks/api/integrationAuth/index.tsx
index c02938985..367534305 100644
--- a/frontend/src/hooks/api/integrationAuth/index.tsx
+++ b/frontend/src/hooks/api/integrationAuth/index.tsx
@@ -1,4 +1,7 @@
export {
useGetIntegrationAuthApps,
useGetIntegrationAuthById,
- useGetIntegrationAuthTeams} from './queries';
\ No newline at end of file
+ useGetIntegrationAuthRailwayEnvironments,
+ useGetIntegrationAuthRailwayServices,
+ useGetIntegrationAuthTeams,
+ useGetIntegrationAuthVercelBranches} from './queries';
\ No newline at end of file
diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx
index 56a6ee45e..02a8b6c51 100644
--- a/frontend/src/hooks/api/integrationAuth/queries.tsx
+++ b/frontend/src/hooks/api/integrationAuth/queries.tsx
@@ -4,13 +4,37 @@ import { apiRequest } from "@app/config/request";
import {
App,
+ Environment,
IntegrationAuth,
- Team} from './types';
+ Service,
+ Team
+} from './types';
const integrationAuthKeys = {
getIntegrationAuthById: (integrationAuthId: string) => [{ integrationAuthId }, 'integrationAuth'] as const,
getIntegrationAuthApps: (integrationAuthId: string, teamId?: string) => [{ integrationAuthId, teamId }, 'integrationAuthApps'] as const,
- getIntegrationAuthTeams: (integrationAuthId: string) => [{ integrationAuthId }, 'integrationAuthTeams'] as const
+ getIntegrationAuthTeams: (integrationAuthId: string) => [{ integrationAuthId }, 'integrationAuthTeams'] as const,
+ getIntegrationAuthVercelBranches: ({
+ integrationAuthId,
+ appId,
+ }: {
+ integrationAuthId: string;
+ appId: string;
+ }) => [{ integrationAuthId, appId }, 'integrationAuthVercelBranches'] as const,
+ getIntegrationAuthRailwayEnvironments: ({
+ integrationAuthId,
+ appId
+ }: {
+ integrationAuthId: string;
+ appId: string;
+ }) => [{ integrationAuthId, appId }, 'integrationAuthRailwayEnvironments'] as const,
+ getIntegrationAuthRailwayServices: ({
+ integrationAuthId,
+ appId
+ }: {
+ integrationAuthId: string;
+ appId: string;
+ }) => [{ integrationAuthId, appId }, 'integrationAuthRailwayServices'] as const
}
const fetchIntegrationAuthById = async (integrationAuthId: string) => {
@@ -38,6 +62,54 @@ const fetchIntegrationAuthTeams = async (integrationAuthId: string) => {
return data.teams;
}
+const fetchIntegrationAuthVercelBranches = async ({
+ integrationAuthId,
+ appId
+}: {
+ integrationAuthId: string;
+ appId: string;
+}) => {
+ const { data: { branches } } = await apiRequest.get<{ branches: string[] }>(`/api/v1/integration-auth/${integrationAuthId}/vercel/branches`, {
+ params: {
+ appId
+ }
+ });
+
+ return branches;
+};
+
+const fetchIntegrationAuthRailwayEnvironments = async ({
+ integrationAuthId,
+ appId
+}: {
+ integrationAuthId: string;
+ appId: string;
+}) => {
+ const { data: { environments } } = await apiRequest.get<{ environments: Environment[] }>(`/api/v1/integration-auth/${integrationAuthId}/railway/environments`, {
+ params: {
+ appId
+ }
+ });
+
+ return environments;
+}
+
+const fetchIntegrationAuthRailwayServices = async ({
+ integrationAuthId,
+ appId
+}: {
+ integrationAuthId: string;
+ appId: string;
+}) => {
+ const { data: { services } } = await apiRequest.get<{ services: Service[] }>(`/api/v1/integration-auth/${integrationAuthId}/railway/services`, {
+ params: {
+ appId
+ }
+ });
+
+ return services;
+}
+
export const useGetIntegrationAuthById = (integrationAuthId: string) => {
return useQuery({
queryKey: integrationAuthKeys.getIntegrationAuthById(integrationAuthId),
@@ -46,7 +118,6 @@ export const useGetIntegrationAuthById = (integrationAuthId: string) => {
});
}
-// TODO: fix to teamId
export const useGetIntegrationAuthApps = ({
integrationAuthId,
teamId
@@ -70,4 +141,64 @@ export const useGetIntegrationAuthTeams = (integrationAuthId: string) => {
queryFn: () => fetchIntegrationAuthTeams(integrationAuthId),
enabled: true
});
+}
+
+export const useGetIntegrationAuthVercelBranches = ({
+ integrationAuthId,
+ appId,
+}: {
+ integrationAuthId: string;
+ appId: string;
+}) => {
+ return useQuery({
+ queryKey: integrationAuthKeys.getIntegrationAuthVercelBranches({
+ integrationAuthId,
+ appId,
+ }),
+ queryFn: () => fetchIntegrationAuthVercelBranches({
+ integrationAuthId,
+ appId,
+ }),
+ enabled: true
+ });
+}
+
+export const useGetIntegrationAuthRailwayEnvironments = ({
+ integrationAuthId,
+ appId
+}: {
+ integrationAuthId: string;
+ appId: string;
+}) => {
+ return useQuery({
+ queryKey: integrationAuthKeys.getIntegrationAuthRailwayEnvironments({
+ integrationAuthId,
+ appId,
+ }),
+ queryFn: () => fetchIntegrationAuthRailwayEnvironments({
+ integrationAuthId,
+ appId,
+ }),
+ enabled: true
+ });
+}
+
+export const useGetIntegrationAuthRailwayServices = ({
+ integrationAuthId,
+ appId
+}: {
+ integrationAuthId: string;
+ appId: string;
+}) => {
+ return useQuery({
+ queryKey: integrationAuthKeys.getIntegrationAuthRailwayServices({
+ integrationAuthId,
+ appId,
+ }),
+ queryFn: () => fetchIntegrationAuthRailwayServices({
+ integrationAuthId,
+ appId,
+ }),
+ enabled: true
+ });
}
\ No newline at end of file
diff --git a/frontend/src/hooks/api/integrationAuth/types.ts b/frontend/src/hooks/api/integrationAuth/types.ts
index 4ba19c192..7ea8799a5 100644
--- a/frontend/src/hooks/api/integrationAuth/types.ts
+++ b/frontend/src/hooks/api/integrationAuth/types.ts
@@ -15,4 +15,14 @@ export type App = {
export type Team = {
name: string;
teamId: string;
+}
+
+export type Environment = {
+ name: string;
+ environmentId: string;
+}
+
+export type Service = {
+ name: string;
+ serviceId: string;
}
\ No newline at end of file
diff --git a/frontend/src/hooks/api/secretSnapshots/index.tsx b/frontend/src/hooks/api/secretSnapshots/index.tsx
new file mode 100644
index 000000000..7d6b368e7
--- /dev/null
+++ b/frontend/src/hooks/api/secretSnapshots/index.tsx
@@ -0,0 +1,6 @@
+export {
+ useGetSnapshotSecrets,
+ useGetWorkspaceSecretSnapshots,
+ useGetWsSnapshotCount,
+ usePerformSecretRollback
+} from './queries';
diff --git a/frontend/src/hooks/api/secretSnapshots/queries.tsx b/frontend/src/hooks/api/secretSnapshots/queries.tsx
new file mode 100644
index 000000000..3aa9dfbe5
--- /dev/null
+++ b/frontend/src/hooks/api/secretSnapshots/queries.tsx
@@ -0,0 +1,154 @@
+/* eslint-disable no-param-reassign */
+import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import {
+ decryptAssymmetric,
+ decryptSymmetric
+} from '@app/components/utilities/cryptography/crypto';
+import { apiRequest } from '@app/config/request';
+
+import { DecryptedSecret } from '../secrets/types';
+import {
+ GetWorkspaceSecretSnapshotsDTO,
+ TSecretRollbackDTO,
+ TSnapshotSecret,
+ TSnapshotSecretProps,
+ TWorkspaceSecretSnapshot
+} from './types';
+
+export const secretSnapshotKeys = {
+ list: (workspaceId: string) => [{ workspaceId }, 'secret-snapshot'] as const,
+ snapshotSecrets: (snapshotId: string) => [{ snapshotId }, 'secret-snapshot'] as const,
+ count: (workspaceId: string) => [{ workspaceId }, 'count', 'secret-snapshot']
+};
+
+const fetchWorkspaceSecretSnaphots = async (workspaceId: string, limit = 10, offset = 0) => {
+ const res = await apiRequest.get<{ secretSnapshots: TWorkspaceSecretSnapshot[] }>(
+ `/api/v1/workspace/${workspaceId}/secret-snapshots`,
+ {
+ params: {
+ limit,
+ offset
+ }
+ }
+ );
+
+ return res.data.secretSnapshots;
+};
+
+export const useGetWorkspaceSecretSnapshots = (dto: GetWorkspaceSecretSnapshotsDTO) =>
+ useInfiniteQuery({
+ enabled: Boolean(dto.workspaceId),
+ queryKey: secretSnapshotKeys.list(dto.workspaceId),
+ queryFn: ({ pageParam }) => fetchWorkspaceSecretSnaphots(dto.workspaceId, dto.limit, pageParam),
+ getNextPageParam: (lastPage, pages) =>
+ lastPage.length !== 0 ? pages.length * dto.limit : undefined
+ });
+
+const fetchSnapshotEncSecrets = async (snapshotId: string) => {
+ const res = await apiRequest.get<{ secretSnapshot: TSnapshotSecret }>(
+ `/api/v1/secret-snapshot/${snapshotId}`
+ );
+ return res.data.secretSnapshot;
+};
+
+export const useGetSnapshotSecrets = ({ decryptFileKey, env, snapshotId }: TSnapshotSecretProps) =>
+ useQuery({
+ queryKey: secretSnapshotKeys.snapshotSecrets(snapshotId),
+ enabled: Boolean(snapshotId && decryptFileKey),
+ queryFn: () => fetchSnapshotEncSecrets(snapshotId),
+ select: (data) => {
+ const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string;
+ const latestKey = decryptFileKey;
+ const key = decryptAssymmetric({
+ ciphertext: latestKey.encryptedKey,
+ nonce: latestKey.nonce,
+ publicKey: latestKey.sender.publicKey,
+ privateKey: PRIVATE_KEY
+ });
+
+ const sharedSecrets: DecryptedSecret[] = [];
+ const personalSecrets: Record
= {};
+ data.secretVersions
+ .filter(({ environment }) => environment === env)
+ .forEach((encSecret) => {
+ const secretKey = decryptSymmetric({
+ ciphertext: encSecret.secretKeyCiphertext,
+ iv: encSecret.secretKeyIV,
+ tag: encSecret.secretKeyTag,
+ key
+ });
+
+ const secretValue = decryptSymmetric({
+ ciphertext: encSecret.secretValueCiphertext,
+ iv: encSecret.secretValueIV,
+ tag: encSecret.secretValueTag,
+ key
+ });
+
+ const secretComment = '';
+
+ const decryptedSecret = {
+ _id: encSecret.secret,
+ env: encSecret.environment,
+ key: secretKey,
+ value: secretValue,
+ tags: encSecret.tags,
+ comment: secretComment,
+ createdAt: encSecret.createdAt,
+ updatedAt: encSecret.updatedAt,
+ type: 'modified'
+ };
+
+ if (encSecret.type === 'personal') {
+ personalSecrets[decryptedSecret.key] = { id: encSecret.secret, value: secretValue };
+ } else {
+ sharedSecrets.push(decryptedSecret);
+ }
+ });
+
+ sharedSecrets.forEach((val) => {
+ if (personalSecrets?.[val.key]) {
+ val.idOverride = personalSecrets[val.key].id;
+ val.valueOverride = personalSecrets[val.key].value;
+ val.overrideAction = 'modified';
+ }
+ });
+
+ return { version: data.version, secrets: sharedSecrets, createdAt: data.createdAt };
+ }
+ });
+
+const fetchWorkspaceSecretSnaphotCount = async (workspaceId: string) => {
+ const res = await apiRequest.get<{ count: number }>(
+ `/api/v1/workspace/${workspaceId}/secret-snapshots/count`
+ );
+ return res.data.count;
+};
+
+export const useGetWsSnapshotCount = (workspaceId: string) =>
+ useQuery({
+ enabled: Boolean(workspaceId),
+ queryKey: secretSnapshotKeys.count(workspaceId),
+ queryFn: () => fetchWorkspaceSecretSnaphotCount(workspaceId)
+ });
+
+export const usePerformSecretRollback = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation<{}, {}, TSecretRollbackDTO>({
+ mutationFn: async (dto) => {
+ const { data } = await apiRequest.post(
+ `/api/v1/workspace/${dto.workspaceId}/secret-snapshots/rollback`,
+ {
+ version: dto.version
+ }
+ );
+ return data;
+ },
+ onSuccess: (_, dto) => {
+ queryClient.invalidateQueries(secretSnapshotKeys.list(dto.workspaceId));
+ queryClient.invalidateQueries(secretSnapshotKeys.count(dto.workspaceId));
+ }
+ });
+};
diff --git a/frontend/src/hooks/api/secretSnapshots/types.ts b/frontend/src/hooks/api/secretSnapshots/types.ts
new file mode 100644
index 000000000..dbe8ccd17
--- /dev/null
+++ b/frontend/src/hooks/api/secretSnapshots/types.ts
@@ -0,0 +1,32 @@
+import { UserWsKeyPair } from '../keys/types';
+import { EncryptedSecretVersion } from '../secrets/types';
+
+export type TWorkspaceSecretSnapshot = {
+ _id: string;
+ workspace: string;
+ version: number;
+ secretVersions: string[];
+ createdAt: string;
+ updatedAt: string;
+ __v: number;
+};
+
+export type TSnapshotSecret = Omit & {
+ secretVersions: EncryptedSecretVersion[];
+};
+
+export type TSnapshotSecretProps = {
+ snapshotId: string;
+ env: string;
+ decryptFileKey: UserWsKeyPair;
+};
+
+export type GetWorkspaceSecretSnapshotsDTO = {
+ workspaceId: string;
+ limit: number;
+};
+
+export type TSecretRollbackDTO = {
+ workspaceId: string;
+ version: number;
+};
diff --git a/frontend/src/hooks/api/secrets/index.ts b/frontend/src/hooks/api/secrets/index.ts
new file mode 100644
index 000000000..c27cecfb1
--- /dev/null
+++ b/frontend/src/hooks/api/secrets/index.ts
@@ -0,0 +1 @@
+export { useBatchSecretsOp, useGetProjectSecrets, useGetSecretVersion } from './queries';
diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx
new file mode 100644
index 000000000..031c661d1
--- /dev/null
+++ b/frontend/src/hooks/api/secrets/queries.tsx
@@ -0,0 +1,197 @@
+/* eslint-disable no-param-reassign */
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import {
+ decryptAssymmetric,
+ decryptSymmetric
+} from '@app/components/utilities/cryptography/crypto';
+import { apiRequest } from '@app/config/request';
+
+import { secretSnapshotKeys } from '../secretSnapshots/queries';
+import {
+ BatchSecretDTO,
+ DecryptedSecret,
+ EncryptedSecret,
+ EncryptedSecretVersion,
+ GetProjectSecretsDTO,
+ GetSecretVersionsDTO
+} from './types';
+
+export const secretKeys = {
+ // this is also used in secretSnapshot part
+ getProjectSecret: (workspaceId: string, env: string | string[]) => [{ workspaceId, env }, 'secrets'],
+ getSecretVersion: (secretId: string) => [{ secretId }, 'secret-versions']
+};
+
+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 allEnvData;
+ // eslint-disable-next-line no-else-return
+ } else {
+ return null;
+ }
+
+};
+
+export const useGetProjectSecrets = ({
+ workspaceId,
+ env,
+ decryptFileKey,
+ isPaused
+}: GetProjectSecretsDTO) =>
+ useQuery({
+ // wait for all values to be available
+ enabled: Boolean(decryptFileKey && workspaceId && env) && !isPaused,
+ queryKey: secretKeys.getProjectSecret(workspaceId, env),
+ queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env),
+ select: (data) => {
+ const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string;
+ const latestKey = decryptFileKey;
+ const key = decryptAssymmetric({
+ ciphertext: latestKey.encryptedKey,
+ nonce: latestKey.nonce,
+ publicKey: latestKey.sender.publicKey,
+ privateKey: PRIVATE_KEY
+ });
+
+ const sharedSecrets: DecryptedSecret[] = [];
+ const personalSecrets: Record = {};
+ // 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: EncryptedSecret) => {
+ const secretKey = decryptSymmetric({
+ ciphertext: encSecret.secretKeyCiphertext,
+ iv: encSecret.secretKeyIV,
+ tag: encSecret.secretKeyTag,
+ key
+ });
+
+ const secretValue = decryptSymmetric({
+ ciphertext: encSecret.secretValueCiphertext,
+ iv: encSecret.secretValueIV,
+ tag: encSecret.secretValueTag,
+ key
+ });
+
+ const secretComment = decryptSymmetric({
+ ciphertext: encSecret.secretCommentCiphertext,
+ iv: encSecret.secretCommentIV,
+ tag: encSecret.secretCommentTag,
+ key
+ });
+
+ const decryptedSecret = {
+ _id: encSecret._id,
+ env: encSecret.environment,
+ key: secretKey,
+ value: secretValue,
+ tags: encSecret.tags,
+ comment: secretComment,
+ createdAt: encSecret.createdAt,
+ updatedAt: encSecret.updatedAt
+ };
+
+ if (encSecret.type === 'personal') {
+ personalSecrets[`${decryptedSecret.key}-${decryptedSecret.env}`] = { id: encSecret._id, value: secretValue };
+ } else {
+ if (!duplicateSecretKey?.[`${decryptedSecret.key}-${decryptedSecret.env}`]) {
+ sharedSecrets.push(decryptedSecret);
+ }
+ duplicateSecretKey[`${decryptedSecret.key}-${decryptedSecret.env}`] = true;
+ }
+ });
+ sharedSecrets.forEach((val) => {
+ if (personalSecrets?.[val.key]) {
+ val.idOverride = personalSecrets[val.key].id;
+ val.valueOverride = personalSecrets[val.key].value;
+ val.overrideAction = 'modified';
+ }
+ });
+
+ return { secrets: sharedSecrets };
+ }
+ });
+
+const fetchEncryptedSecretVersion = async (secretId: string, offset: number, limit: number) => {
+ const { data } = await apiRequest.get<{ secretVersions: EncryptedSecretVersion[] }>(
+ `/api/v1/secret/${secretId}/secret-versions`,
+ {
+ params: {
+ limit,
+ offset
+ }
+ }
+ );
+ return data.secretVersions;
+};
+
+export const useGetSecretVersion = (dto: GetSecretVersionsDTO) =>
+ useQuery({
+ enabled: Boolean(dto.secretId && dto.decryptFileKey),
+ queryKey: secretKeys.getSecretVersion(dto.secretId),
+ queryFn: () => fetchEncryptedSecretVersion(dto.secretId, dto.offset, dto.limit),
+ select: (data) => {
+ const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string;
+ const latestKey = dto.decryptFileKey;
+ const key = decryptAssymmetric({
+ ciphertext: latestKey.encryptedKey,
+ nonce: latestKey.nonce,
+ publicKey: latestKey.sender.publicKey,
+ privateKey: PRIVATE_KEY
+ });
+
+ return data
+ .map((el) => ({
+ createdAt: el.createdAt,
+ id: el._id,
+ value: decryptSymmetric({
+ ciphertext: el.secretValueCiphertext,
+ iv: el.secretValueIV,
+ tag: el.secretValueTag,
+ key
+ })
+ }))
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
+ }
+ });
+
+export const useBatchSecretsOp = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation<{}, {}, BatchSecretDTO>({
+ mutationFn: async (dto) => {
+ const { data } = await apiRequest.post('/api/v2/secrets/batch', dto);
+ return data;
+ },
+ onSuccess: (_, dto) => {
+ queryClient.invalidateQueries(secretKeys.getProjectSecret(dto.workspaceId, dto.environment));
+ queryClient.invalidateQueries(secretSnapshotKeys.list(dto.workspaceId));
+ queryClient.invalidateQueries(secretSnapshotKeys.count(dto.workspaceId));
+ }
+ });
+};
diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts
new file mode 100644
index 000000000..567fecc9a
--- /dev/null
+++ b/frontend/src/hooks/api/secrets/types.ts
@@ -0,0 +1,104 @@
+import type { UserWsKeyPair } from '../keys/types';
+import type { WsTag } from '../tags/types';
+
+export type EncryptedSecret = {
+ _id: string;
+ version: number;
+ workspace: string;
+ type: 'shared' | 'personal';
+ environment: string;
+ secretKeyCiphertext: string;
+ secretKeyIV: string;
+ secretKeyTag: string;
+ secretValueCiphertext: string;
+ secretValueIV: string;
+ secretValueTag: string;
+ __v: number;
+ createdAt: string;
+ updatedAt: string;
+ secretCommentCiphertext: string;
+ secretCommentIV: string;
+ secretCommentTag: string;
+ tags: WsTag[];
+};
+
+export type DecryptedSecret = {
+ _id: string;
+ key: string;
+ value: string;
+ comment: string;
+ tags: WsTag[];
+ createdAt: string;
+ updatedAt: string;
+ env: string;
+ valueOverride?: string;
+ idOverride?: string;
+ overrideAction?: string;
+};
+
+export type EncryptedSecretVersion = {
+ _id: string;
+ secret: string;
+ version: number;
+ workspace: string;
+ type: string;
+ environment: string;
+ isDeleted: boolean;
+ secretKeyCiphertext: string;
+ secretKeyIV: string;
+ secretKeyTag: string;
+ secretValueCiphertext: string;
+ secretValueIV: string;
+ secretValueTag: string;
+ tags: WsTag[];
+ __v: number;
+ createdAt: string;
+ updatedAt: string;
+};
+
+// dto
+type SecretTagArg = { _id: string; name: string; slug: string };
+
+export type UpdateSecretArg = {
+ _id: string;
+ type: 'shared' | 'personal';
+ secretKeyCiphertext: string;
+ secretKeyIV: string;
+ secretKeyTag: string;
+ secretValueCiphertext: string;
+ secretValueIV: string;
+ secretValueTag: string;
+ secretCommentCiphertext: string;
+ secretCommentIV: string;
+ secretCommentTag: string;
+ tags: SecretTagArg[];
+};
+
+export type CreateSecretArg = Omit;
+
+export type DeleteSecretArg = { _id: string };
+
+export type BatchSecretDTO = {
+ workspaceId: string;
+ environment: string;
+ requests: Array<
+ | { method: 'POST'; secret: CreateSecretArg }
+ | { method: 'PATCH'; secret: UpdateSecretArg }
+ | { method: 'DELETE'; secret: DeleteSecretArg }
+ >;
+};
+
+export type GetProjectSecretsDTO = {
+ workspaceId: string;
+ env: string | string[];
+ decryptFileKey: UserWsKeyPair;
+ isPaused?: boolean;
+ onSuccess?: (data: DecryptedSecret[]) => void;
+};
+
+export type GetSecretVersionsDTO = {
+ secretId: string;
+ limit: number;
+ offset: number;
+ decryptFileKey: UserWsKeyPair;
+};
diff --git a/frontend/src/hooks/api/serviceAccounts/index.tsx b/frontend/src/hooks/api/serviceAccounts/index.tsx
new file mode 100644
index 000000000..a063d40dd
--- /dev/null
+++ b/frontend/src/hooks/api/serviceAccounts/index.tsx
@@ -0,0 +1,10 @@
+export {
+ useCreateServiceAccount,
+ useCreateServiceAccountProjectLevelPermission,
+ useDeleteServiceAccount,
+ useDeleteServiceAccountProjectLevelPermission,
+ useGetServiceAccountById,
+ useGetServiceAccountProjectLevelPermissions,
+ useGetServiceAccounts,
+ useRenameServiceAccount
+} from './queries';
\ No newline at end of file
diff --git a/frontend/src/hooks/api/serviceAccounts/queries.tsx b/frontend/src/hooks/api/serviceAccounts/queries.tsx
new file mode 100644
index 000000000..ec7888547
--- /dev/null
+++ b/frontend/src/hooks/api/serviceAccounts/queries.tsx
@@ -0,0 +1,137 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import { apiRequest } from '@app/config/request';
+
+import {
+ CreateServiceAccountDTO,
+ CreateServiceAccountRes,
+ CreateServiceAccountWorkspacePermissionDTO,
+ DeleteServiceAccountWorkspacePermissionDTO,
+ RenameServiceAccountDTO,
+ ServiceAccount,
+ ServiceAccountWorkspacePermission
+} 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({
+ 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({
+ 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({
+ 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: { serviceAccountWorkspacePermissions } } = await apiRequest.get<{ serviceAccountWorkspacePermissions: ServiceAccountWorkspacePermission[] }>(
+ `/api/v2/service-accounts/${serviceAccountId}/permissions/workspace`
+ );
+
+ return serviceAccountWorkspacePermissions;
+}
+
+export const useGetServiceAccountProjectLevelPermissions = (serviceAccountId: string) => {
+ return useQuery({
+ queryKey: serviceAccountKeys.getServiceAccountProjectLevelPermissions(serviceAccountId),
+ queryFn: () => fetchServiceAccountProjectLevelPermissions(serviceAccountId),
+ enabled: true
+ });
+}
+
+export const useCreateServiceAccountProjectLevelPermission = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (body) => {
+ const { data: { serviceAccountWorkspacePermission } } = await apiRequest.post(`/api/v2/service-accounts/${body.serviceAccountId}/permissions/workspace`, body);
+ return serviceAccountWorkspacePermission;
+ },
+ onSuccess: ({ serviceAccount }) => {
+ queryClient.invalidateQueries(serviceAccountKeys.getServiceAccountProjectLevelPermissions(serviceAccount));
+ }
+ });
+}
+
+export const useDeleteServiceAccountProjectLevelPermission = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ serviceAccountId, serviceAccountWorkspacePermissionId }) => {
+ const { data: { serviceAccountWorkspacePermission} } = await apiRequest.delete(`/api/v2/service-accounts/${serviceAccountId}/permissions/workspace/${serviceAccountWorkspacePermissionId}`);
+ return serviceAccountWorkspacePermission;
+ },
+ onSuccess: (serviceAccountWorkspacePermission) => {
+ queryClient.invalidateQueries(serviceAccountKeys.getServiceAccountProjectLevelPermissions(serviceAccountWorkspacePermission.serviceAccount));
+ }
+ });
+}
\ No newline at end of file
diff --git a/frontend/src/hooks/api/serviceAccounts/types.ts b/frontend/src/hooks/api/serviceAccounts/types.ts
new file mode 100644
index 000000000..f8865729b
--- /dev/null
+++ b/frontend/src/hooks/api/serviceAccounts/types.ts
@@ -0,0 +1,51 @@
+import { Workspace } from '../workspace/types';
+
+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 RenameServiceAccountDTO = {
+ serviceAccountId: string;
+ name: string;
+}
+
+export type ServiceAccountWorkspacePermission = {
+ _id: string;
+ serviceAccount: string;
+ workspace: Workspace;
+ environment: string;
+ read: boolean;
+ write: boolean;
+}
+
+export type CreateServiceAccountWorkspacePermissionDTO = {
+ serviceAccountId: string;
+ workspaceId: string;
+ environment: string;
+ read: boolean;
+ write: boolean;
+ encryptedKey: string;
+ nonce: string;
+}
+
+export type DeleteServiceAccountWorkspacePermissionDTO = {
+ serviceAccountId: string;
+ serviceAccountWorkspacePermissionId: string;
+}
\ No newline at end of file
diff --git a/frontend/src/hooks/api/types.ts b/frontend/src/hooks/api/types.ts
index a8821e5a5..554521828 100644
--- a/frontend/src/hooks/api/types.ts
+++ b/frontend/src/hooks/api/types.ts
@@ -4,6 +4,7 @@ export type { UserWsKeyPair } from './keys/types';
export type { Organization } from './organization/types';
export type { CreateServiceTokenDTO, ServiceToken } from './serviceTokens/types';
export type { GetSubscriptionPlan, SubscriptionPlan } from './subscriptions/types';
+export type { WsTag } from './tags/types';
export type { AddUserToWsDTO, AddUserToWsRes, OrgUser, User } from './users/types';
export type {
CreateEnvironmentDTO,
diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx
index 0980d4108..d6cfcab2b 100644
--- a/frontend/src/hooks/api/users/index.tsx
+++ b/frontend/src/hooks/api/users/index.tsx
@@ -5,6 +5,8 @@ export {
useDeleteOrgMembership,
useGetOrgUsers,
useGetUser,
+ useGetUserAction,
useLogoutUser,
+ useRegisterUserAction,
useUpdateOrgUserRole
} from './queries';
diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx
index 5d0759755..ef6dcc726 100644
--- a/frontend/src/hooks/api/users/queries.tsx
+++ b/frontend/src/hooks/api/users/queries.tsx
@@ -20,6 +20,7 @@ import {
const userKeys = {
getUser: ['user'] as const,
+ userAction: ['user-action'] as const,
getOrgUsers: (orgId: string) => [{ orgId }, 'user']
};
@@ -31,6 +32,21 @@ const fetchUserDetails = async () => {
export const useGetUser = () => useQuery(userKeys.getUser, fetchUserDetails);
+const fetchUserAction = async (action: string) => {
+ const { data } = await apiRequest.get<{ userAction: string }>('/api/v1/user-action', {
+ params: {
+ action
+ }
+ });
+ return data.userAction;
+};
+
+export const useGetUserAction = (action: string) =>
+ useQuery({
+ queryKey: userKeys.userAction,
+ queryFn: () => fetchUserAction(action)
+ });
+
export const fetchOrgUsers = async (orgId: string) => {
const { data } = await apiRequest.get<{ users: OrgUser[] }>(
`/api/v1/organization/${orgId}/users`
@@ -128,6 +144,16 @@ export const useUpdateOrgUserRole = () => {
});
};
+export const useRegisterUserAction = () => {
+ const queryClient = useQueryClient();
+ return useMutation<{}, {}, string>({
+ mutationFn: (action) => apiRequest.post('/api/v1/user-action', { action }),
+ onSuccess: () => {
+ queryClient.invalidateQueries(userKeys.userAction);
+ }
+ });
+};
+
export const useLogoutUser = () =>
useMutation({
mutationFn: () => apiRequest.post('/api/v1/auth/logout'),
diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx
index cab8617bd..622c5196c 100644
--- a/frontend/src/hooks/api/workspace/index.tsx
+++ b/frontend/src/hooks/api/workspace/index.tsx
@@ -5,7 +5,9 @@ export {
useDeleteWsEnvironment,
useGetUserWorkspaceMemberships,
useGetUserWorkspaces,
+ useGetUserWsEnvironments,
useGetWorkspaceById,
useRenameWorkspace,
useToggleAutoCapitalization,
- useUpdateWsEnvironment} from './queries';
+ useUpdateWsEnvironment
+} from './queries';
diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx
index 2bca2e9a5..4736da076 100644
--- a/frontend/src/hooks/api/workspace/queries.tsx
+++ b/frontend/src/hooks/api/workspace/queries.tsx
@@ -7,16 +7,19 @@ import {
CreateWorkspaceDTO,
DeleteEnvironmentDTO,
DeleteWorkspaceDTO,
+ GetWsEnvironmentDTO,
RenameWorkspaceDTO,
ToggleAutoCapitalizationDTO,
UpdateEnvironmentDTO,
- Workspace
+ Workspace,
+ WorkspaceEnv
} from './types';
const workspaceKeys = {
getWorkspaceById: (workspaceId: string) => [{ workspaceId }, 'workspace'] as const,
getWorkspaceMemberships: (orgId: string) => [{ orgId }, 'workspace-memberships'],
- getAllUserWorkspace: ['workspaces'] as const
+ getAllUserWorkspace: ['workspaces'] as const,
+ getUserWsEnvironments: (workspaceId: string) => ['workspace-env', { workspaceId }] as const
};
const fetchWorkspaceById = async (workspaceId: string) => {
@@ -49,6 +52,21 @@ const fetchUserWorkspaceMemberships = async (orgId: string) => {
return data;
};
+const fetchUserWsEnvironments = async (workspaceId: string) => {
+ const { data } = await apiRequest.get<{ accessibleEnvironments: WorkspaceEnv[] }>(
+ `/api/v2/workspace/${workspaceId}/environments`
+ );
+ return data.accessibleEnvironments;
+};
+
+export const useGetUserWsEnvironments = ({ workspaceId, onSuccess }: GetWsEnvironmentDTO) =>
+ useQuery({
+ enabled: Boolean(workspaceId),
+ onSuccess,
+ queryKey: workspaceKeys.getUserWsEnvironments(workspaceId),
+ queryFn: () => fetchUserWsEnvironments(workspaceId)
+ });
+
// to get all userids in an org with the workspace they are part of
export const useGetUserWorkspaceMemberships = (orgId: string) =>
useQuery({
diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts
index 738f5188c..44d4c4322 100644
--- a/frontend/src/hooks/api/workspace/types.ts
+++ b/frontend/src/hooks/api/workspace/types.ts
@@ -7,7 +7,13 @@ export type Workspace = {
environments: WorkspaceEnv[];
};
-export type WorkspaceEnv = { name: string; slug: string };
+export type WorkspaceEnv = {
+ name: string;
+ slug: string;
+ isReadDenied: boolean;
+ isWriteDenied: boolean;
+};
+
export type WorkspaceTag = { _id: string; name: string; slug: string };
// mutation dto
@@ -16,6 +22,11 @@ export type CreateWorkspaceDTO = {
organizationId: string;
};
+export type GetWsEnvironmentDTO = {
+ workspaceId: string;
+ onSuccess?: (data: WorkspaceEnv[]) => void;
+};
+
export type RenameWorkspaceDTO = { workspaceID: string; newWorkspaceName: string };
export type ToggleAutoCapitalizationDTO = { workspaceID: string; state: boolean };
diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts
index 94bb7abc0..2c2058ef7 100644
--- a/frontend/src/hooks/index.ts
+++ b/frontend/src/hooks/index.ts
@@ -1,3 +1,4 @@
export { useLeaveConfirm } from './useLeaveConfirm';
+export { usePersistentState } from './usePersistentState';
export { usePopUp } from './usePopUp';
export { useToggle } from './useToggle';
diff --git a/frontend/src/hooks/useLeaveConfirm.tsx b/frontend/src/hooks/useLeaveConfirm.tsx
index e136f41e4..3cbba8e19 100644
--- a/frontend/src/hooks/useLeaveConfirm.tsx
+++ b/frontend/src/hooks/useLeaveConfirm.tsx
@@ -4,52 +4,56 @@ import { useRouter } from 'next/router';
import { leaveConfirmDefaultMessage } from '@app/const';
type LeaveConfirmProps = {
- initialValue: boolean,
- message?: string
-}
+ initialValue: boolean;
+ message?: string;
+};
interface LeaveConfirmReturn {
- hasUnsavedChanges: boolean,
- setHasUnsavedChanges: Dispatch>,
+ hasUnsavedChanges: boolean;
+ setHasUnsavedChanges: Dispatch>;
}
export function useLeaveConfirm({
- initialValue,
- message = leaveConfirmDefaultMessage,
+ initialValue,
+ message = leaveConfirmDefaultMessage
}: LeaveConfirmProps): LeaveConfirmReturn {
- const router = useRouter()
+ const router = useRouter();
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(initialValue);
const onRouteChangeStart = useCallback(() => {
if (hasUnsavedChanges) {
+ // eslint-disable-next-line no-alert
if (window.confirm(message)) {
- return true
+ return true;
}
- throw new Error("Abort route change by user's confirmation.")
+ throw new Error("Abort route change by user's confirmation.");
}
return false;
- }, [hasUnsavedChanges])
+ }, [hasUnsavedChanges]);
- const handleWindowClose = useCallback((e: any) => {
- if (!hasUnsavedChanges) {
- return;
- }
- e.preventDefault();
- e.returnValue = message;
- }, []);
+ const handleWindowClose = useCallback(
+ (e: any) => {
+ if (!hasUnsavedChanges) {
+ return;
+ }
+ e.preventDefault();
+ e.returnValue = message;
+ },
+ [hasUnsavedChanges]
+ );
useEffect(() => {
- router.events.on("routeChangeStart", onRouteChangeStart);
+ router.events.on('routeChangeStart', onRouteChangeStart);
window.addEventListener('beforeunload', handleWindowClose);
return () => {
- router.events.off("routeChangeStart", onRouteChangeStart);
+ router.events.off('routeChangeStart', onRouteChangeStart);
window.removeEventListener('beforeunload', handleWindowClose);
- }
+ };
}, [onRouteChangeStart, handleWindowClose]);
return {
hasUnsavedChanges,
- setHasUnsavedChanges,
+ setHasUnsavedChanges
};
}
diff --git a/frontend/src/hooks/usePersistentState.ts b/frontend/src/hooks/usePersistentState.ts
new file mode 100644
index 000000000..0230e35ef
--- /dev/null
+++ b/frontend/src/hooks/usePersistentState.ts
@@ -0,0 +1,25 @@
+import { useEffect, useState } from 'react';
+
+type TPersisntentStateReturn = [T, (val: T) => void];
+
+export const usePersistentState = (
+ initialValue: T,
+ persistenceKey: string
+): TPersisntentStateReturn => {
+ const [val, setVal] = useState(initialValue);
+
+ useEffect(() => {
+ const temp = localStorage.getItem(persistenceKey);
+ if (temp) {
+ const { key } = JSON.parse(temp);
+ setVal(key);
+ }
+ }, []);
+
+ const setState = (state: T) => {
+ localStorage.setItem(persistenceKey, JSON.stringify({ key: state }));
+ setVal(state);
+ };
+
+ return [val, setState];
+};
diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx
index 46b339287..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';
@@ -93,8 +94,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 +115,38 @@ 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 lastPathSegments = router.asPath.split('/').pop();
+ if (lastPathSegments !== undefined) {
+ [intendedWorkspaceId] = lastPathSegments.split('?');
+ }
+
+ // const lastPathSegment = router.asPath.split('/').pop().split('?');
+ // [intendedWorkspaceId] = lastPathSegment;
+ }
+
+ if (!intendedWorkspaceId) return;
if (!['callback', 'create', 'authorize'].includes(intendedWorkspaceId)) {
localStorage.setItem('projectData.id', intendedWorkspaceId);
@@ -129,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(
@@ -192,7 +225,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 }) => {
@@ -243,11 +275,12 @@ export const AppLayout = ({ children }: LayoutProps) => {
{name}
))}
-
+ {/* */}
handlePopUpOpen('addNewWs')}
leftIcon={ }
diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx
index 2b5dd0fc2..30d2623b5 100644
--- a/frontend/src/pages/_app.tsx
+++ b/frontend/src/pages/_app.tsx
@@ -8,6 +8,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
import NotificationProvider from '@app/components/context/Notifications/NotificationProvider';
import Telemetry from '@app/components/utilities/telemetry/Telemetry';
+import { TooltipProvider } from '@app/components/v2';
import { publicPaths } from '@app/const';
import {
AuthProvider,
@@ -73,21 +74,23 @@ const App = ({ Component, pageProps, ...appProps }: NextAppProp): JSX.Element =>
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
};
diff --git a/frontend/src/pages/activity/[id].tsx b/frontend/src/pages/activity/[id].tsx
index 8e16fccd7..c43a6745d 100644
--- a/frontend/src/pages/activity/[id].tsx
+++ b/frontend/src/pages/activity/[id].tsx
@@ -20,6 +20,12 @@ interface LogData {
user: {
email: string;
};
+ serviceAccount?: {
+ string: string;
+ },
+ serviceTokenData?: {
+ name: string;
+ }
actions: {
_id: string;
name: string;
@@ -41,6 +47,12 @@ interface LogDataPoint {
createdAt: string;
ipAddress: string;
user: string;
+ serviceAccount: {
+ name: string;
+ };
+ serviceTokenData: {
+ name: string;
+ };
payload: PayloadProps[];
}
@@ -69,13 +81,16 @@ export default function Activity() {
userId: '',
actionNames: eventChosen
});
+
setLogsData(
tempLogsData.map((log: LogData) => ({
_id: log._id,
channel: log.channel,
createdAt: log.createdAt,
ipAddress: log.ipAddress,
- user: log.user.email,
+ user: log?.user?.email,
+ serviceAccount: log?.serviceAccount,
+ serviceTokenData: log?.serviceTokenData,
payload: log.actions.map((action) => ({
_id: action._id,
name: action.name,
@@ -106,7 +121,9 @@ export default function Activity() {
channel: log.channel,
createdAt: log.createdAt,
ipAddress: log.ipAddress,
- user: log.user.email,
+ user: log?.user?.email,
+ serviceAccount: log?.serviceAccount,
+ serviceTokenData: log?.serviceTokenData,
payload: log.actions.map((action) => ({
_id: action._id,
name: action.name,
diff --git a/frontend/src/pages/api/integrations/DeleteIntegration.ts b/frontend/src/pages/api/integrations/DeleteIntegration.ts
index a16c629a3..ae401984c 100644
--- a/frontend/src/pages/api/integrations/DeleteIntegration.ts
+++ b/frontend/src/pages/api/integrations/DeleteIntegration.ts
@@ -19,7 +19,6 @@ const deleteIntegration = ({ integrationId }: Props) =>
if (res && res.status === 200) {
return (await res.json()).integration;
}
- console.log('Failed to delete an integration');
return undefined;
});
diff --git a/frontend/src/pages/api/integrations/createIntegration.ts b/frontend/src/pages/api/integrations/createIntegration.ts
index d861223f1..e3e7c010a 100644
--- a/frontend/src/pages/api/integrations/createIntegration.ts
+++ b/frontend/src/pages/api/integrations/createIntegration.ts
@@ -7,6 +7,9 @@ interface Props {
appId: string | null;
sourceEnvironment: string;
targetEnvironment: string | null;
+ targetEnvironmentId: string | null;
+ targetService: string | null;
+ targetServiceId: string | null;
owner: string | null;
path: string | null;
region: string | null;
@@ -24,6 +27,9 @@ const createIntegration = ({
appId,
sourceEnvironment,
targetEnvironment,
+ targetEnvironmentId,
+ targetService,
+ targetServiceId,
owner,
path,
region
@@ -40,6 +46,9 @@ const createIntegration = ({
appId,
sourceEnvironment,
targetEnvironment,
+ targetEnvironmentId,
+ targetService,
+ targetServiceId,
owner,
path,
region
diff --git a/frontend/src/pages/api/workspace/getLatestFileKey.ts b/frontend/src/pages/api/workspace/getLatestFileKey.ts
index 576fcf943..f649987db 100644
--- a/frontend/src/pages/api/workspace/getLatestFileKey.ts
+++ b/frontend/src/pages/api/workspace/getLatestFileKey.ts
@@ -1,22 +1,13 @@
-import SecurityClient from '@app/components/utilities/SecurityClient';
+import { apiRequest } from '@app/config/request';
/**
* Get the latest key pairs from a certain workspace
* @param {string} workspaceId
* @returns
*/
-const getLatestFileKey = ({ workspaceId }: { workspaceId: string }) =>
- SecurityClient.fetchCall(`/api/v1/key/${workspaceId}/latest`, {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json'
- }
- }).then(async (res) => {
- if (res?.status === 200) {
- return res.json();
- }
- console.log('Failed to get the latest key pairs for a certain project');
- return undefined;
- });
+const getLatestFileKey = async ({ workspaceId }: { workspaceId: string }) => {
+ const { data } = await apiRequest.get(`/api/v1/key/${workspaceId}/latest`);
+ return data;
+}
export default getLatestFileKey;
diff --git a/frontend/src/pages/dashboard/[id].tsx b/frontend/src/pages/dashboard/[id].tsx
index 799c05c39..8aa480c81 100644
--- a/frontend/src/pages/dashboard/[id].tsx
+++ b/frontend/src/pages/dashboard/[id].tsx
@@ -19,9 +19,9 @@ import {
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Tag } from 'public/data/frequentInterfaces';
+import queryString from 'query-string';
import Button from '@app/components/basic/buttons/Button';
-import ListBox from '@app/components/basic/Listbox';
import BottonRightPopup from '@app/components/basic/popups/BottomRightPopup';
import { useNotificationContext } from '@app/components/context/Notifications/NotificationProvider';
import ConfirmEnvOverwriteModal from '@app/components/dashboard/ConfirmEnvOverwriteModal';
@@ -41,6 +41,8 @@ import getProjectSercetSnapshotsCount from '@app/ee/api/secrets/GetProjectSercet
import performSecretRollback from '@app/ee/api/secrets/PerformSecretRollback';
import PITRecoverySidebar from '@app/ee/components/PITRecoverySidebar';
import { useLeaveConfirm } from '@app/hooks';
+// import { DashboardPage } from '@app/views/DashboardPage';
+import { DashboardEnvOverview } from '@app/views/DashboardPage/DashboardEnvOverview';
// import addSecrets from '../api/files/AddSecrets';
// import deleteSecrets from '../api/files/DeleteSecrets';
@@ -124,7 +126,7 @@ interface SecretProps {
}
/**
- * this function finds the teh duplicates in an array
+ * this function finds the the duplicates in an array
* @param arr - array of anything (e.g., with secret keys and types (personal/shared))
* @returns - a list with duplicates
*/
@@ -147,7 +149,6 @@ function findDuplicates(arr: any[]) {
export default function Dashboard() {
const [data, setData] = useState();
const [initialData, setInitialData] = useState([]);
- const router = useRouter();
const [blurred, setBlurred] = useState(true);
const [isKeyAvailable, setIsKeyAvailable] = useState(true);
const [isNew, setIsNew] = useState(false);
@@ -169,7 +170,10 @@ export default function Dashboard() {
const { hasUnsavedChanges, setHasUnsavedChanges } = useLeaveConfirm({ initialValue: false });
const { t } = useTranslation();
+
const { createNotification } = useNotificationContext();
+ const router = useRouter();
+ const envInURL = queryString.parse(router.asPath.split('?')[1])?.env;
const workspaceId = router.query.id as string;
const [workspaceEnvs, setWorkspaceEnvs] = useState([]);
@@ -783,13 +787,13 @@ export default function Dashboard() {
deleteRow({ ids, secretName });
};
- const handleOnEnvironmentChange = (envName: string) => {
+ const handleOnEnvironmentChange = (envSlug: string) => {
if (hasUnsavedChanges) {
if (!window.confirm(leaveConfirmDefaultMessage)) return;
}
const selectedWorkspaceEnv = workspaceEnvs.find(
- ({ name }: { name: string }) => envName === name
+ ({ slug }: { slug: string }) => envSlug === slug
) || {
name: 'unknown',
slug: 'unknown',
@@ -797,6 +801,8 @@ export default function Dashboard() {
isReadDenied: false
};
+ console.log(124, envSlug, selectedWorkspaceEnv)
+
if (selectedWorkspaceEnv) {
if (snapshotData) setSelectedSnapshotEnv(selectedWorkspaceEnv);
else setSelectedEnv(selectedWorkspaceEnv);
@@ -809,7 +815,10 @@ export default function Dashboard() {
})
};
- return data ? (
+ return
+ {!envInURL
+ ?
+ : (data ? (
{t('common:head-title', { title: t('dashboard:title') })}
@@ -835,7 +844,13 @@ export default function Dashboard() {
}
/>
-
+
envir.slug === envInURL)[0].name || ''}
+ isProjectRelated
+ userAvailableEnvs={workspaceEnvs}
+ onEnvChange={handleOnEnvironmentChange}
+ />
{checkDocsPopUpVisible && (
setSnapshotData(undefined)}
color="mineshaft"
size="md"
@@ -863,7 +878,7 @@ export default function Dashboard() {
{snapshotData ? 'Secret Snapshot' : t('dashboard:title')}
{snapshotData && (
-
+
{new Date(snapshotData.createdAt).toLocaleString()}
)}
@@ -873,13 +888,6 @@ export default function Dashboard() {
)}
- {!snapshotData && data?.length === 0 && selectedEnv && (
- name)}
- onChange={handleOnEnvironmentChange}
- />
- )}
@@ -960,20 +968,7 @@ export default function Dashboard() {
{(snapshotData || data?.length !== 0) && selectedEnv && (
<>
- {!snapshotData ? (
-
name)}
- onChange={handleOnEnvironmentChange}
- />
- ) : (
- name)}
- onChange={handleOnEnvironmentChange}
- />
- )}
-
+
@@ -1223,10 +1218,10 @@ export default function Dashboard() {
) : (
-
}
Dashboard.requireAuth = true;
diff --git a/frontend/src/pages/integrations/[id].tsx b/frontend/src/pages/integrations/[id].tsx
index c3c625a79..46870805d 100644
--- a/frontend/src/pages/integrations/[id].tsx
+++ b/frontend/src/pages/integrations/[id].tsx
@@ -207,6 +207,12 @@ export default function Integrations() {
case 'travisci':
link = `${window.location.origin}/integrations/travisci/authorize`;
break;
+ case 'supabase':
+ link = `${window.location.origin}/integrations/supabase/authorize`;
+ break;
+ case 'railway':
+ link = `${window.location.origin}/integrations/railway/authorize`;
+ break;
default:
break;
}
@@ -259,6 +265,12 @@ export default function Integrations() {
case 'travisci':
link = `${window.location.origin}/integrations/travisci/create?integrationAuthId=${integrationAuth._id}`;
break;
+ case 'supabase':
+ link = `${window.location.origin}/integrations/supabase/create?integrationAuthId=${integrationAuth._id}`;
+ break;
+ case 'railway':
+ link = `${window.location.origin}/integrations/railway/create?integrationAuthId=${integrationAuth._id}`;
+ break;
default:
break;
}
diff --git a/frontend/src/pages/integrations/aws-parameter-store/create.tsx b/frontend/src/pages/integrations/aws-parameter-store/create.tsx
index 624f5937a..200628921 100644
--- a/frontend/src/pages/integrations/aws-parameter-store/create.tsx
+++ b/frontend/src/pages/integrations/aws-parameter-store/create.tsx
@@ -98,6 +98,9 @@ export default function AWSParameterStoreCreateIntegrationPage() {
appId: null,
sourceEnvironment: selectedSourceEnvironment,
targetEnvironment: null,
+ targetEnvironmentId: null,
+ targetService: null,
+ targetServiceId: null,
owner: null,
path,
region: selectedAWSRegion
diff --git a/frontend/src/pages/integrations/aws-secret-manager/create.tsx b/frontend/src/pages/integrations/aws-secret-manager/create.tsx
index 804cf0504..7f253e119 100644
--- a/frontend/src/pages/integrations/aws-secret-manager/create.tsx
+++ b/frontend/src/pages/integrations/aws-secret-manager/create.tsx
@@ -97,6 +97,9 @@ export default function AWSSecretManagerCreateIntegrationPage() {
appId: null,
sourceEnvironment: selectedSourceEnvironment,
targetEnvironment: null,
+ targetEnvironmentId: null,
+ targetService: null,
+ targetServiceId: null,
owner: null,
path: null,
region: selectedAWSRegion
diff --git a/frontend/src/pages/integrations/azure-key-vault/create.tsx b/frontend/src/pages/integrations/azure-key-vault/create.tsx
index bfd544748..8f506b6d4 100644
--- a/frontend/src/pages/integrations/azure-key-vault/create.tsx
+++ b/frontend/src/pages/integrations/azure-key-vault/create.tsx
@@ -62,6 +62,9 @@ export default function AzureKeyVaultCreateIntegrationPage() {
appId: null,
sourceEnvironment: selectedSourceEnvironment,
targetEnvironment: null,
+ targetEnvironmentId: null,
+ targetService: null,
+ targetServiceId: null,
owner: null,
path: null,
region: null
diff --git a/frontend/src/pages/integrations/circleci/create.tsx b/frontend/src/pages/integrations/circleci/create.tsx
index b753e7dd7..05a53e609 100644
--- a/frontend/src/pages/integrations/circleci/create.tsx
+++ b/frontend/src/pages/integrations/circleci/create.tsx
@@ -60,6 +60,9 @@ export default function CircleCICreateIntegrationPage() {
appId: (integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.name === targetApp))?.appId ?? null,
sourceEnvironment: selectedSourceEnvironment,
targetEnvironment: null,
+ targetEnvironmentId: null,
+ targetService: null,
+ targetServiceId: null,
owner: null,
path: null,
region: null,
diff --git a/frontend/src/pages/integrations/flyio/create.tsx b/frontend/src/pages/integrations/flyio/create.tsx
index 05b37affd..13dbc05d1 100644
--- a/frontend/src/pages/integrations/flyio/create.tsx
+++ b/frontend/src/pages/integrations/flyio/create.tsx
@@ -61,6 +61,9 @@ export default function FlyioCreateIntegrationPage() {
appId: null,
sourceEnvironment: selectedSourceEnvironment,
targetEnvironment: null,
+ targetEnvironmentId: null,
+ targetService: null,
+ targetServiceId: null,
owner: null,
path: null,
region: null
diff --git a/frontend/src/pages/integrations/github/create.tsx b/frontend/src/pages/integrations/github/create.tsx
index 5fbd8f780..eee9f8a71 100644
--- a/frontend/src/pages/integrations/github/create.tsx
+++ b/frontend/src/pages/integrations/github/create.tsx
@@ -27,8 +27,7 @@ export default function GitHubCreateIntegrationPage() {
});
const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState('');
- const [owner, setOwner] = useState(null);
- const [targetApp, setTargetApp] = useState('');
+ const [targetAppId, setTargetAppId] = useState('');
const [isLoading, setIsLoading] = useState(false);
@@ -41,10 +40,9 @@ export default function GitHubCreateIntegrationPage() {
useEffect(() => {
if (integrationAuthApps) {
if (integrationAuthApps.length > 0) {
- setTargetApp(integrationAuthApps[0].name);
- setOwner(integrationAuthApps[0]?.owner ?? null);
+ setTargetAppId(integrationAuthApps[0].appId as string);
} else {
- setTargetApp('none');
+ setTargetAppId('none');
}
}
}, [integrationAuthApps]);
@@ -55,14 +53,21 @@ export default function GitHubCreateIntegrationPage() {
if (!integrationAuth?._id) return;
+ const targetApp = integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.appId === targetAppId);
+
+ if (!targetApp || !targetApp.owner) return;
+
await createIntegration({
integrationAuthId: integrationAuth?._id,
isActive: true,
- app: targetApp,
+ app: targetApp.name,
appId: null,
sourceEnvironment: selectedSourceEnvironment,
targetEnvironment: null,
- owner,
+ targetEnvironmentId: null,
+ targetService: null,
+ targetServiceId: null,
+ owner: targetApp.owner,
path: null,
region: null
});
@@ -76,7 +81,7 @@ export default function GitHubCreateIntegrationPage() {
}
}
- return (integrationAuth && workspace && selectedSourceEnvironment && integrationAuthApps && targetApp) ? (
+ return (integrationAuth && workspace && selectedSourceEnvironment && integrationAuthApps && targetAppId) ? (
GitHub Integration
@@ -101,14 +106,14 @@ export default function GitHubCreateIntegrationPage() {
className='mt-4'
>
setTargetApp(val)}
+ value={targetAppId}
+ onValueChange={(val) => setTargetAppId(val)}
className='w-full border border-mineshaft-500'
isDisabled={integrationAuthApps.length === 0}
>
{integrationAuthApps.length > 0 ? (
integrationAuthApps.map((integrationAuthApp) => (
-
+
{integrationAuthApp.name}
))
diff --git a/frontend/src/pages/integrations/gitlab/create.tsx b/frontend/src/pages/integrations/gitlab/create.tsx
index 259285109..c8a25e78b 100644
--- a/frontend/src/pages/integrations/gitlab/create.tsx
+++ b/frontend/src/pages/integrations/gitlab/create.tsx
@@ -89,6 +89,9 @@ export default function GitLabCreateIntegrationPage() {
appId: targetAppId,
sourceEnvironment: selectedSourceEnvironment,
targetEnvironment: null,
+ targetEnvironmentId: null,
+ targetService: null,
+ targetServiceId: null,
owner: null,
path: null,
region: null
@@ -109,7 +112,6 @@ export default function GitLabCreateIntegrationPage() {
GitLab Integration
integrationAuthApp.name === targetApp))?.appId ?? null,
sourceEnvironment: selectedSourceEnvironment,
targetEnvironment,
+ targetEnvironmentId: null,
+ targetService: null,
+ targetServiceId: null,
owner: null,
path: null,
region: null
diff --git a/frontend/src/pages/integrations/railway/authorize.tsx b/frontend/src/pages/integrations/railway/authorize.tsx
new file mode 100644
index 000000000..94d3f06bf
--- /dev/null
+++ b/frontend/src/pages/integrations/railway/authorize.tsx
@@ -0,0 +1,77 @@
+import { useState } from 'react';
+import { useRouter } from 'next/router';
+
+import { getTranslatedServerSideProps } from '../../../components/utilities/withTranslateProps';
+import {
+ Button,
+ Card,
+ CardTitle,
+ FormControl,
+ Input,
+} from '../../../components/v2';
+import saveIntegrationAccessToken from "../../api/integrations/saveIntegrationAccessToken";
+
+export default function RailwayAuthorizeIntegrationPage() {
+ const router = useRouter();
+ const [apiKey, setApiKey] = useState('');
+ const [apiKeyErrorText, setApiKeyErrorText] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleButtonClick = async () => {
+ try {
+ setApiKeyErrorText('');
+ if (apiKey.length === 0) {
+ setApiKeyErrorText('API Key cannot be blank');
+ return;
+ }
+
+ setIsLoading(true);
+
+ const integrationAuth = await saveIntegrationAccessToken({
+ workspaceId: localStorage.getItem('projectData.id'),
+ integration: 'railway',
+ accessId: null,
+ accessToken: apiKey
+ });
+
+ setIsLoading(false);
+
+ router.push(
+ `/integrations/railway/create?integrationAuthId=${integrationAuth._id}`
+ );
+ } catch (err) {
+ console.error(err);
+ }
+ }
+
+ return (
+
+
+ Railway Integration
+
+ setApiKey(e.target.value)}
+ />
+
+
+ Connect to Railway
+
+
+
+ );
+}
+
+RailwayAuthorizeIntegrationPage.requireAuth = true;
+
+export const getServerSideProps = getTranslatedServerSideProps(['integrations']);
\ No newline at end of file
diff --git a/frontend/src/pages/integrations/railway/create.tsx b/frontend/src/pages/integrations/railway/create.tsx
new file mode 100644
index 000000000..95eb90c6f
--- /dev/null
+++ b/frontend/src/pages/integrations/railway/create.tsx
@@ -0,0 +1,207 @@
+import { useEffect, useState } from 'react';
+import { useRouter } from 'next/router';
+import queryString from 'query-string';
+
+import { getTranslatedServerSideProps } from '../../../components/utilities/withTranslateProps';
+import {
+ Button,
+ Card,
+ CardTitle,
+ FormControl,
+ Select,
+ SelectItem
+} from '../../../components/v2';
+import {
+ useGetIntegrationAuthApps,
+ useGetIntegrationAuthById,
+ useGetIntegrationAuthRailwayEnvironments,
+ useGetIntegrationAuthRailwayServices
+} from '../../../hooks/api/integrationAuth';
+import { useGetWorkspaceById } from '../../../hooks/api/workspace';
+import createIntegration from "../../api/integrations/createIntegration";
+
+export default function RailwayCreateIntegrationPage() {
+ const router = useRouter();
+
+ const [targetAppId, setTargetAppId] = useState('');
+ const [targetEnvironmentId, setTargetEnvironmentId] = useState('');
+ const [targetServiceId, setTargetServiceId] = useState('');
+
+ const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+
+ const { integrationAuthId } = queryString.parse(router.asPath.split('?')[1]);
+ const { data: integrationAuth } = useGetIntegrationAuthById(integrationAuthId as string ?? '');
+ const { data: workspace } = useGetWorkspaceById(localStorage.getItem('projectData.id') ?? '');
+ const { data: integrationAuthApps } = useGetIntegrationAuthApps({
+ integrationAuthId: integrationAuthId as string ?? ''
+ });
+ const { data: targetEnvironments } = useGetIntegrationAuthRailwayEnvironments({
+ integrationAuthId: integrationAuthId as string ?? '',
+ appId: targetAppId
+ });
+ const { data: targetServices } = useGetIntegrationAuthRailwayServices({
+ integrationAuthId: integrationAuthId as string ?? '',
+ appId: targetAppId
+ });
+
+ useEffect(() => {
+ if (workspace) {
+ setSelectedSourceEnvironment(workspace.environments[0].slug);
+ }
+ }, [workspace]);
+
+ useEffect(() => {
+ if (integrationAuthApps) {
+ if (integrationAuthApps.length > 0) {
+ setTargetAppId(integrationAuthApps[0].appId as string);
+ } else {
+ setTargetAppId('none');
+ }
+ }
+ }, [integrationAuthApps]);
+
+ useEffect(() => {
+ if (targetEnvironments) {
+ if (targetEnvironments.length > 0) {
+ setTargetEnvironmentId(targetEnvironments[0].environmentId);
+ } else {
+ setTargetEnvironmentId('none');
+ }
+ }
+ }, [targetEnvironments]);
+
+ const filteredServices = targetServices
+ ?.concat({
+ name: '',
+ serviceId: ''
+ });
+
+ const handleButtonClick = async () => {
+ try {
+ setIsLoading(true);
+
+ if (!integrationAuth?._id) return;
+
+ const targetApp = integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.appId === targetAppId);
+ const targetEnvironment = targetEnvironments?.find((environment) => environment.environmentId === targetEnvironmentId);
+
+ if (!targetApp || !targetApp.appId || !targetEnvironment) return;
+
+ const targetService = targetServices?.find((service) => service.serviceId === targetServiceId);
+
+ await createIntegration({
+ integrationAuthId: integrationAuth?._id,
+ isActive: true,
+ app: targetApp.name,
+ appId: targetApp.appId,
+ sourceEnvironment: selectedSourceEnvironment,
+ targetEnvironment: targetEnvironment.name,
+ targetEnvironmentId: targetEnvironment.environmentId,
+ targetService: targetService ? targetService.name : null,
+ targetServiceId: targetService ? targetService.serviceId : null,
+ owner: null,
+ path: null,
+ region: null
+ });
+
+ setIsLoading(false);
+
+ router.push(
+ `/integrations/${localStorage.getItem('projectData.id')}`
+ );
+ } catch (err) {
+ console.error(err);
+ }
+ }
+
+ return workspace && selectedSourceEnvironment && integrationAuthApps && targetEnvironments && filteredServices ? (
+
+
+ Railway Integration
+
+ setSelectedSourceEnvironment(val)}
+ className='w-full border border-mineshaft-500'
+ >
+ {workspace?.environments.map((sourceEnvironment) => (
+
+ {sourceEnvironment.name}
+
+ ))}
+
+
+
+ setTargetAppId(val)}
+ className='w-full border border-mineshaft-500'
+ isDisabled={integrationAuthApps.length === 0}
+ >
+ {integrationAuthApps.length > 0 ? (
+ integrationAuthApps.map((integrationAuthApp) => (
+
+ {integrationAuthApp.name}
+
+ ))
+ ) : (
+
+ No projects found
+
+ )}
+
+
+
+ setTargetEnvironmentId(val)}
+ className='w-full border border-mineshaft-500'
+ >
+ {targetEnvironments.length > 0 ? (
+ targetEnvironments.map((targetEnvironment) => (
+
+ {targetEnvironment.name}
+
+ ))
+ ) : (
+
+ No environments found
+
+ )}
+
+
+
+ setTargetServiceId(val)}
+ className='w-full border border-mineshaft-500'
+ >
+ {filteredServices.map((targetService) => (
+
+ {targetService.name}
+
+ ))}
+
+
+
+ Create Integration
+
+
+
+ ) :
+}
+
+RailwayCreateIntegrationPage.requireAuth = true;
+
+export const getServerSideProps = getTranslatedServerSideProps(['integrations']);
+
diff --git a/frontend/src/pages/integrations/render/create.tsx b/frontend/src/pages/integrations/render/create.tsx
index cc61eec3e..ce9d702ee 100644
--- a/frontend/src/pages/integrations/render/create.tsx
+++ b/frontend/src/pages/integrations/render/create.tsx
@@ -11,7 +11,7 @@ import {
Select,
SelectItem
} from '../../../components/v2';
-import { useGetIntegrationAuthApps,useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth';
+import { useGetIntegrationAuthApps, useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth';
import { useGetWorkspaceById } from '../../../hooks/api/workspace';
import createIntegration from "../../api/integrations/createIntegration";
@@ -38,7 +38,6 @@ export default function RenderCreateIntegrationPage() {
}, [workspace]);
useEffect(() => {
- // TODO: handle case where apps can be empty
if (integrationAuthApps) {
if (integrationAuthApps.length > 0) {
setTargetApp(integrationAuthApps[0].name);
@@ -61,6 +60,9 @@ export default function RenderCreateIntegrationPage() {
appId: (integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.name === targetApp))?.appId ?? null,
sourceEnvironment: selectedSourceEnvironment,
targetEnvironment: null,
+ targetEnvironmentId: null,
+ targetService: null,
+ targetServiceId: null,
owner: null,
path: null,
region: null
diff --git a/frontend/src/pages/integrations/supabase/authorize.tsx b/frontend/src/pages/integrations/supabase/authorize.tsx
new file mode 100644
index 000000000..95785be6f
--- /dev/null
+++ b/frontend/src/pages/integrations/supabase/authorize.tsx
@@ -0,0 +1,65 @@
+import { useState } from 'react';
+import { useRouter } from 'next/router';
+
+import { getTranslatedServerSideProps } from '../../../components/utilities/withTranslateProps';
+import { Button, Card, CardTitle, FormControl, Input } from '../../../components/v2';
+import saveIntegrationAccessToken from '../../api/integrations/saveIntegrationAccessToken';
+
+export default function SupabaseCreateIntegrationPage() {
+ const router = useRouter();
+ const [apiKey, setApiKey] = useState('');
+ const [apiKeyErrorText, setApiKeyErrorText] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleButtonClick = async () => {
+ try {
+ setApiKeyErrorText('');
+ if (apiKey.length === 0) {
+ setApiKeyErrorText('API Key cannot be blank');
+ return;
+ }
+
+ setIsLoading(true);
+
+ const integrationAuth = await saveIntegrationAccessToken({
+ workspaceId: localStorage.getItem('projectData.id'),
+ integration: 'supabase',
+ accessToken: apiKey,
+ accessId: null
+ });
+
+ setIsLoading(false);
+
+ router.push(`/integrations/supabase/create?integrationAuthId=${integrationAuth._id}`);
+ } catch (err) {
+ console.error(err);
+ }
+ };
+
+ return (
+
+
+ Supabase Integration
+
+ setApiKey(e.target.value)} />
+
+
+ Connect to Supabase
+
+
+
+ );
+}
+
+SupabaseCreateIntegrationPage.requireAuth = true;
+
+export const getServerSideProps = getTranslatedServerSideProps(['integrations']);
\ No newline at end of file
diff --git a/frontend/src/pages/integrations/supabase/create.tsx b/frontend/src/pages/integrations/supabase/create.tsx
new file mode 100644
index 000000000..16e35e36e
--- /dev/null
+++ b/frontend/src/pages/integrations/supabase/create.tsx
@@ -0,0 +1,142 @@
+import { useEffect, useState } from 'react';
+import { useRouter } from 'next/router';
+import queryString from 'query-string';
+
+import { getTranslatedServerSideProps } from '../../../components/utilities/withTranslateProps';
+import { Button, Card, CardTitle, FormControl, Select, SelectItem } from '../../../components/v2';
+import {
+ useGetIntegrationAuthApps,
+ useGetIntegrationAuthById
+} from '../../../hooks/api/integrationAuth';
+import { useGetWorkspaceById } from '../../../hooks/api/workspace';
+import createIntegration from '../../api/integrations/createIntegration';
+
+export default function SupabaseCreateIntegrationPage() {
+ const router = useRouter();
+
+ const { integrationAuthId } = queryString.parse(router.asPath.split('?')[1]);
+
+ const { data: workspace } = useGetWorkspaceById(localStorage.getItem('projectData.id') ?? '');
+ const { data: integrationAuth } = useGetIntegrationAuthById((integrationAuthId as string) ?? '');
+ const { data: integrationAuthApps } = useGetIntegrationAuthApps({
+ integrationAuthId: (integrationAuthId as string) ?? ''
+ });
+
+ const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState('');
+ const [targetApp, setTargetApp] = useState('');
+
+ const [isLoading, setIsLoading] = useState(false);
+
+ useEffect(() => {
+ if (workspace) {
+ setSelectedSourceEnvironment(workspace.environments[0].slug);
+ }
+ }, [workspace]);
+
+ useEffect(() => {
+ if (integrationAuthApps) {
+ if (integrationAuthApps.length > 0) {
+ setTargetApp(integrationAuthApps[0].name);
+ } else {
+ setTargetApp('none');
+ }
+ }
+ }, [integrationAuthApps]);
+
+ const handleButtonClick = async () => {
+ try {
+ if (!integrationAuth?._id) return;
+
+ setIsLoading(true);
+
+ await createIntegration({
+ integrationAuthId: integrationAuth?._id,
+ isActive: true,
+ app: targetApp,
+ appId:
+ integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.name === targetApp)
+ ?.appId ?? null,
+ sourceEnvironment: selectedSourceEnvironment,
+ targetEnvironment: null,
+ targetEnvironmentId: null,
+ targetService: null,
+ targetServiceId: null,
+ owner: null,
+ path: null,
+ region: null
+ });
+
+ setIsLoading(false);
+
+ router.push(`/integrations/${localStorage.getItem('projectData.id')}`);
+ } catch (err) {
+ console.error(err);
+ }
+ };
+
+ return integrationAuth &&
+ workspace &&
+ selectedSourceEnvironment &&
+ integrationAuthApps &&
+ targetApp ? (
+
+
+ Supabase Integration
+
+ setSelectedSourceEnvironment(val)}
+ className="w-full border border-mineshaft-500"
+ >
+ {workspace?.environments.map((sourceEnvironment) => (
+
+ {sourceEnvironment.name}
+
+ ))}
+
+
+
+ setTargetApp(val)}
+ className="w-full border border-mineshaft-500"
+ isDisabled={integrationAuthApps.length === 0}
+ >
+ {integrationAuthApps.length > 0 ? (
+ integrationAuthApps.map((integrationAuthApp) => (
+
+ {integrationAuthApp.name}
+
+ ))
+ ) : (
+
+ No projects found
+
+ )}
+
+
+
+ Create Integration
+
+
+
+ ) : (
+
+ );
+}
+
+SupabaseCreateIntegrationPage.requireAuth = true;
+
+export const getServerSideProps = getTranslatedServerSideProps(['integrations']);
\ No newline at end of file
diff --git a/frontend/src/pages/integrations/travisci/create.tsx b/frontend/src/pages/integrations/travisci/create.tsx
index c4a8a8f95..7f4e93422 100644
--- a/frontend/src/pages/integrations/travisci/create.tsx
+++ b/frontend/src/pages/integrations/travisci/create.tsx
@@ -60,6 +60,9 @@ export default function TravisCICreateIntegrationPage() {
appId: (integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.name === targetApp))?.appId ?? null,
sourceEnvironment: selectedSourceEnvironment,
targetEnvironment: null,
+ targetEnvironmentId: null,
+ targetService: null,
+ targetServiceId: null,
owner: null,
path: null,
region: null,
diff --git a/frontend/src/pages/integrations/vercel/create.tsx b/frontend/src/pages/integrations/vercel/create.tsx
index 6431b4597..531033957 100644
--- a/frontend/src/pages/integrations/vercel/create.tsx
+++ b/frontend/src/pages/integrations/vercel/create.tsx
@@ -4,14 +4,18 @@ import queryString from 'query-string';
import { getTranslatedServerSideProps } from '../../../components/utilities/withTranslateProps';
import {
- Button,
- Card,
- CardTitle,
- FormControl,
- Select,
- SelectItem
+ Button,
+ Card,
+ CardTitle,
+ FormControl,
+ Select,
+ SelectItem
} from '../../../components/v2';
-import { useGetIntegrationAuthApps,useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth';
+import {
+ useGetIntegrationAuthApps,
+ useGetIntegrationAuthById,
+ useGetIntegrationAuthVercelBranches
+} from '../../../hooks/api/integrationAuth';
import { useGetWorkspaceById } from '../../../hooks/api/workspace';
import createIntegration from "../../api/integrations/createIntegration";
@@ -24,20 +28,28 @@ const vercelEnvironments = [
export default function VercelCreateIntegrationPage() {
const router = useRouter();
- const { integrationAuthId } = queryString.parse(router.asPath.split('?')[1]);
+ const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState('');
+ const [targetAppId, setTargetAppId] = useState('');
+ const [targetEnvironment, setTargetEnvironment] = useState('');
+ const [targetBranch, setTargetBranch] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const { integrationAuthId } = queryString.parse(router.asPath.split('?')[1]);
const { data: workspace } = useGetWorkspaceById(localStorage.getItem('projectData.id') ?? '');
const { data: integrationAuth } = useGetIntegrationAuthById(integrationAuthId as string ?? '');
const { data: integrationAuthApps } = useGetIntegrationAuthApps({
integrationAuthId: integrationAuthId as string ?? ''
});
- const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState('');
- const [targetApp, setTargetApp] = useState('');
- const [targetEnvironment, setTargetEnvironment] = useState('');
-
- const [isLoading, setIsLoading] = useState(false);
+ const { data: branches } = useGetIntegrationAuthVercelBranches({
+ integrationAuthId: integrationAuthId as string,
+ appId: targetAppId,
+ });
+ const filteredBranches = branches
+ ?.filter((branchName) => branchName !== 'main')
+ .concat('');
+
useEffect(() => {
if (workspace) {
setSelectedSourceEnvironment(workspace.environments[0].slug);
@@ -45,15 +57,15 @@ export default function VercelCreateIntegrationPage() {
}, [workspace]);
useEffect(() => {
- if (integrationAuthApps) {
- if (integrationAuthApps.length > 0) {
- setTargetApp(integrationAuthApps[0].name);
- setTargetEnvironment(vercelEnvironments[0].slug);
- } else {
- setTargetApp('none');
- setTargetEnvironment(vercelEnvironments[0].slug);
- }
+ if (integrationAuthApps) {
+ if (integrationAuthApps.length > 0) {
+ setTargetAppId(integrationAuthApps[0].appId as string);
+ setTargetEnvironment(vercelEnvironments[0].slug);
+ } else {
+ setTargetAppId('none');
+ setTargetEnvironment(vercelEnvironments[0].slug);
}
+ }
}, [integrationAuthApps]);
const handleButtonClick = async () => {
@@ -61,15 +73,25 @@ export default function VercelCreateIntegrationPage() {
if (!integrationAuth?._id) return;
setIsLoading(true);
+
+ const targetApp = integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.appId === targetAppId);
+
+ if (!targetApp || !targetApp.appId) return;
+
+ const path = (targetEnvironment === 'preview' && targetBranch !== '') ? targetBranch : null;
+
await createIntegration({
integrationAuthId: integrationAuth?._id,
isActive: true,
- app: targetApp,
- appId: null,
+ app: targetApp.name,
+ appId: targetApp.appId,
sourceEnvironment: selectedSourceEnvironment,
targetEnvironment,
+ targetEnvironmentId: null,
+ targetService: null,
+ targetServiceId: null,
owner: null,
- path: null,
+ path,
region: null
});
@@ -82,7 +104,7 @@ export default function VercelCreateIntegrationPage() {
}
}
- return (integrationAuth && workspace && selectedSourceEnvironment && integrationAuthApps && targetApp && targetEnvironment) ? (
+ return (integrationAuth && workspace && selectedSourceEnvironment && integrationAuthApps && targetAppId && targetEnvironment) ? (
Vercel Integration
@@ -106,14 +128,14 @@ export default function VercelCreateIntegrationPage() {
label="Vercel App"
>
setTargetApp(val)}
+ value={targetAppId}
+ onValueChange={(val) => setTargetAppId(val)}
className='w-full border border-mineshaft-500'
isDisabled={integrationAuthApps.length === 0}
>
{integrationAuthApps.length > 0 ? (
integrationAuthApps.map((integrationAuthApp) => (
-
+
{integrationAuthApp.name}
))
@@ -139,6 +161,23 @@ export default function VercelCreateIntegrationPage() {
))}
+ {targetEnvironment === 'preview' && filteredBranches && (
+
+ setTargetBranch(val)}
+ className='w-full border border-mineshaft-500'
+ >
+ {filteredBranches.map((branchName) => (
+
+ {branchName}
+
+ ))}
+
+
+ )}
+
+ Edit Service Account
+
+
+
+ >
+ );
+}
+
+ServiceAccountPage.requireAuth = true;
+
+export const getServerSideProps = getTranslatedServerSideProps([
+ 'settings',
+ 'settings-org',
+ 'section-incident',
+ 'section-members'
+]);
\ No newline at end of file
diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css
index df32da8d0..feca862f6 100644
--- a/frontend/src/styles/globals.css
+++ b/frontend/src/styles/globals.css
@@ -2,6 +2,42 @@
@tailwind components;
@tailwind utilities;
+@layer utilities {
+ .flex-0 {
+ flex:0;
+ }
+ .flex-2 {
+ flex-grow: 2;
+ }
+
+ .flex-3 {
+ flex-grow: 3;
+ }
+}
+
+@layer components {
+ .secret-table {
+ @apply bg-mineshaft-800 text-left text-bunker-300 w-full;
+ }
+
+ /* padding except for comment column */
+ .secret-table th {
+ @apply py-2 px-4 font-medium;
+ }
+
+ .secret-table td {
+ @apply py-1 px-1 pr-2 text-sm;
+ }
+
+ .secret-table th:not(:last-child),.secret-table td:not(:last-child) {
+ @apply border-r border-mineshaft-600;
+ }
+
+ .secret-table tr {
+ @apply border-b border-mineshaft-600;
+ }
+}
+
@import '@fontsource/inter/400.css';
@import '@fontsource/inter/500.css';
-@import '@fontsource/inter/700.css';
\ No newline at end of file
+@import '@fontsource/inter/700.css';
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 (
+
+
+
+ );
+ }
+
+ // 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 (
+
+
+
+ {
+ handlePopUpToggle('addTag', open);
+ }}
+ >
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/views/DashboardPage/DashboardPage.tsx b/frontend/src/views/DashboardPage/DashboardPage.tsx
new file mode 100644
index 000000000..75a42c0df
--- /dev/null
+++ b/frontend/src/views/DashboardPage/DashboardPage.tsx
@@ -0,0 +1,662 @@
+import { useEffect, useState } from 'react';
+import { FormProvider, useFieldArray, useForm, useWatch } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { useRouter } from 'next/router';
+import {
+ faArrowLeft,
+ faCheck,
+ faClockRotateLeft,
+ faCodeCommit,
+ faDownload,
+ faEye,
+ faEyeSlash,
+ faMagnifyingGlass,
+ faPlus
+} from '@fortawesome/free-solid-svg-icons';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { yupResolver } from '@hookform/resolvers/yup';
+import { useQueryClient } from '@tanstack/react-query';
+
+import { useNotificationContext } from '@app/components/context/Notifications/NotificationProvider';
+import NavHeader from '@app/components/navigation/NavHeader';
+import {
+ Button,
+ IconButton,
+ Input,
+ Modal,
+ ModalContent,
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+ TableContainer,
+ Tag,
+ Tooltip
+} from '@app/components/v2';
+import { leaveConfirmDefaultMessage } from '@app/const';
+import { useWorkspace } from '@app/context';
+import { useLeaveConfirm, usePopUp, useToggle } from '@app/hooks';
+import {
+ useBatchSecretsOp,
+ useCreateWsTag,
+ useGetProjectSecrets,
+ useGetSecretVersion,
+ useGetSnapshotSecrets,
+ useGetUserAction,
+ useGetUserWsEnvironments,
+ useGetUserWsKey,
+ useGetWorkspaceSecretSnapshots,
+ useGetWsSnapshotCount,
+ useGetWsTags,
+ usePerformSecretRollback,
+ useRegisterUserAction
+} from '@app/hooks/api';
+import { secretKeys } from '@app/hooks/api/secrets/queries';
+import { WorkspaceEnv } from '@app/hooks/api/types';
+
+import { CompareSecret } from './components/CompareSecret';
+import { CreateTagModal } from './components/CreateTagModal';
+import { PitDrawer } from './components/PitDrawer';
+import { SecretDetailDrawer } from './components/SecretDetailDrawer';
+import { SecretDropzone } from './components/SecretDropzone';
+import { SecretInputRow } from './components/SecretInputRow';
+import { SecretTableHeader } from './components/SecretTableHeader';
+import {
+ DEFAULT_SECRET_VALUE,
+ downloadSecret,
+ FormData,
+ schema,
+ transformSecretsToBatchSecretReq,
+ TSecOverwriteOpt,
+ TSecretDetailsOpen
+} from './DashboardPage.utils';
+
+const USER_ACTION_PUSH = 'first_time_secrets_pushed';
+
+/*
+ * Some imp aspects to consider. Here there are multiple stats changing
+ * Thus ideally we need to use a context. But instead we rely on react hook form
+ * React hook form provides context and high performance proxy based rendering
+ * It also handles error handling and transferring states between inputs
+ *
+ * Another thing is the purpose of overrideAction
+ * Before we would remove the value for personal secret when user toggle and user couldn't get it back
+ * They have to reload the browser or go back all over again
+ * 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 = ({ envFromTop }: { envFromTop: string }) => {
+ const { t } = useTranslation();
+ const router = useRouter();
+ const { createNotification } = useNotificationContext();
+ const queryClient = useQueryClient();
+
+ const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
+ 'secretDetails',
+ 'addTag',
+ 'secretSnapshots',
+ 'uploadedSecOpts',
+ 'compareSecrets'
+ ] as const);
+ const [isSecretValueHidden, setIsSecretValueHidden] = useToggle(true);
+ const [searchFilter, setSearchFilter] = useState('');
+ const [snapshotId, setSnaphotId] = useState(null);
+ const [selectedEnv, setSelectedEnv] = useState(null);
+ const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
+ const [deletedSecretIds, setDeletedSecretIds] = useState([]);
+ const { hasUnsavedChanges, setHasUnsavedChanges } = useLeaveConfirm({ initialValue: false });
+
+ 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]);
+
+ // fetching data
+ const { data: userAction } = useGetUserAction(USER_ACTION_PUSH);
+ const hasUserPushed = Boolean(userAction);
+
+ const { data: wsEnv, isLoading: isEnvListLoading } = useGetUserWsEnvironments({
+ workspaceId,
+ onSuccess: (data) => {
+ // get an env with one of the access available
+ const env = data.find(({ isReadDenied, isWriteDenied }) => !isWriteDenied || !isReadDenied);
+ if (env && data?.map(wsenv => wsenv.slug).includes(envFromTop)) {
+ setSelectedEnv(data?.filter(dp => dp.slug === envFromTop)[0]);
+ }
+ }
+ });
+
+ const { data: secretVersion } = useGetSecretVersion({
+ limit: 10,
+ offset: 0,
+ secretId: (popUp?.secretDetails?.data as TSecretDetailsOpen)?.id,
+ decryptFileKey: latestFileKey!
+ });
+
+ const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
+ workspaceId,
+ env: selectedEnv?.slug || '',
+ decryptFileKey: latestFileKey!,
+ isPaused: Boolean(snapshotId)
+ });
+
+ const {
+ data: secretSnaphots,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage
+ } = useGetWorkspaceSecretSnapshots({
+ workspaceId,
+ limit: 10
+ });
+
+ const {
+ data: snapshotSecret,
+ isLoading: isSnapshotSecretsLoading,
+ isFetching: isSnapshotChanging
+ } = useGetSnapshotSecrets({
+ snapshotId: snapshotId || '',
+ env: selectedEnv?.slug || '',
+ decryptFileKey: latestFileKey!
+ });
+
+ const { data: snapshotCount, isLoading: isLoadingSnapshotCount } =
+ useGetWsSnapshotCount(workspaceId);
+
+ const { data: wsTags } = useGetWsTags(workspaceId);
+ // mutation calls
+ const { mutateAsync: batchSecretOp } = useBatchSecretsOp();
+ const { mutateAsync: performSecretRollback } = usePerformSecretRollback();
+ const { mutateAsync: registerUserAction } = useRegisterUserAction();
+ 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 { fields, prepend, append, remove, update } = useFieldArray({ control, name: 'secrets' });
+
+ const isRollbackMode = Boolean(snapshotId);
+ const isReadOnly = selectedEnv?.isWriteDenied;
+ const isAddOnly = selectedEnv?.isReadDenied && !selectedEnv?.isWriteDenied;
+ const canDoRollback = !isReadOnly && !isAddOnly;
+ const isSubmitDisabled =
+ isReadOnly ||
+ // on add only mode the formstate becomes dirty due to secrets missing some items
+ // to avoid this we check dirtyFields in isAddOnly Mode
+ (isAddOnly && Object.keys(dirtyFields).length === 0) ||
+ (!isRollbackMode && !isAddOnly && Object.keys(dirtyFields).length === 0) ||
+ isSubmitting;
+
+ useEffect(() => {
+ if (!isSnapshotChanging && Boolean(snapshotId)) {
+ reset({ secrets: snapshotSecret?.secrets, isSnapshotMode: true });
+ }
+ }, [isSnapshotChanging]);
+
+ useEffect(() => {
+ setHasUnsavedChanges(!isSubmitDisabled);
+ }, [isSubmitDisabled]);
+
+ const onSortSecrets = () => {
+ const dir = sortDir === 'asc' ? 'desc' : 'asc';
+ const sec = getValues('secrets') || [];
+ const sortedSec = sec.sort((a, b) =>
+ dir === 'asc' ? a?.key?.localeCompare(b?.key || '') : b?.key?.localeCompare(a?.key || '')
+ );
+ setValue('secrets', sortedSec);
+ setSortDir(dir);
+ };
+
+ const handleUploadedEnv = (uploadedSec: TSecOverwriteOpt['secrets']) => {
+ const sec = getValues('secrets') || [];
+ const conflictingSec = sec.filter(({ key }) => Boolean(uploadedSec?.[key]));
+ const conflictingSecIds = conflictingSec.reduce>(
+ (prev, curr) => ({
+ ...prev,
+ [curr.key]: true
+ }),
+ {}
+ );
+ // filter to get all conflicting ones
+ const conflictingUploadedSec = { ...uploadedSec };
+ // append non conflicting ones
+ Object.keys(uploadedSec).forEach((key) => {
+ if (!conflictingSecIds?.[key]) {
+ append({
+ ...DEFAULT_SECRET_VALUE,
+ key,
+ value: uploadedSec[key].value,
+ comment: uploadedSec[key].comments.join(',')
+ });
+ delete conflictingUploadedSec[key];
+ }
+ });
+ if (conflictingSec.length > 0) {
+ handlePopUpOpen('uploadedSecOpts', { secrets: conflictingUploadedSec });
+ }
+ };
+
+ const onOverwriteSecrets = () => {
+ const sec = getValues('secrets') || [];
+ const uploadedSec = (popUp?.uploadedSecOpts?.data as TSecOverwriteOpt)?.secrets;
+ const data: Array<{ key: string; index: number }> = [];
+ sec.forEach(({ key }, index) => {
+ if (uploadedSec?.[key]) data.push({ key, index });
+ });
+ data.forEach(({ key, index }) => {
+ const { value, comments } = uploadedSec[key];
+ const comment = comments.join(', ');
+ update(index, {
+ ...DEFAULT_SECRET_VALUE,
+ key,
+ value,
+ comment,
+ tags: sec[index].tags
+ });
+ });
+ handlePopUpClose('uploadedSecOpts');
+ };
+
+ const onSecretRollback = async () => {
+ if (!snapshotSecret?.version) {
+ createNotification({
+ text: 'Failed to find secret version',
+ type: 'success'
+ });
+ return;
+ }
+ try {
+ await performSecretRollback({
+ workspaceId,
+ version: snapshotSecret.version
+ });
+ setValue('isSnapshotMode', false);
+ setSnaphotId(null);
+ queryClient.invalidateQueries(
+ secretKeys.getProjectSecret(workspaceId, selectedEnv?.slug || '')
+ );
+ createNotification({
+ text: 'Successfully rollback secrets',
+ type: 'success'
+ });
+ } catch (error) {
+ console.log(error);
+ createNotification({
+ text: 'Failed to rollback secrets',
+ type: 'error'
+ });
+ }
+ };
+
+ const onAppendSecret = () => append(DEFAULT_SECRET_VALUE);
+
+ const onSaveSecret = async ({ secrets: userSec = [], isSnapshotMode }: FormData) => {
+ if (isSnapshotMode) {
+ await onSecretRollback();
+ return;
+ }
+ // just closing this if save is triggered from drawer
+ handlePopUpClose('secretDetails');
+ // when add only mode remove rest of things not created
+ const sec = isAddOnly ? userSec.filter(({ _id }) => !_id) : userSec;
+ // encrypt and format the secrets to batch api format
+ // requests = [ {method:"", secret:""} ]
+ const batchedSecret = transformSecretsToBatchSecretReq(deletedSecretIds, latestFileKey, sec);
+ // type check
+ if (!selectedEnv?.slug) return;
+ try {
+ await batchSecretOp({
+ requests: batchedSecret,
+ workspaceId,
+ environment: selectedEnv?.slug
+ });
+ createNotification({
+ text: 'Successfully saved changes',
+ type: 'success'
+ });
+ if (!hasUserPushed) {
+ await registerUserAction(USER_ACTION_PUSH);
+ }
+ } catch (error) {
+ console.log(error);
+ createNotification({
+ text: 'Failed to save changes',
+ type: 'error'
+ });
+ }
+ };
+
+ const onDrawerOpen = (dto: TSecretDetailsOpen) => {
+ handlePopUpOpen('secretDetails', dto);
+ };
+
+ const onEnvChange = (slug: string) => {
+ if (hasUnsavedChanges) {
+ // eslint-disable-next-line no-alert
+ if (!window.confirm(leaveConfirmDefaultMessage)) return;
+ }
+ const env = wsEnv?.find((el) => el.slug === slug);
+ if (env) setSelectedEnv(env);
+ router.push(`${router.asPath.split("?")[0]}?env=${slug}`)
+ };
+
+ // record all deleted ids
+ // This will make final deletion easier
+ const onSecretDelete = (index: number, id?: string, overrideId?: string) => {
+ const ids: string[] = [];
+ if (id) ids.push(id);
+ if (overrideId) ids.push(overrideId);
+ setDeletedSecretIds((state) => [...state, ...ids]);
+ remove(index);
+ // just the case if this is called from drawer
+ handlePopUpClose('secretDetails');
+ };
+
+ 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 (
+
+
+
+ );
+ }
+
+ // when secrets is not loading and secrets list is empty
+ const isDashboardSecretEmpty = !isSecretsLoading && !formSecrets?.length;
+ // when using snapshot mode and snapshot is loading and snapshot list is empty
+ const isSnapshotSecretEmtpy =
+ isRollbackMode && !isSnapshotSecretsLoading && !snapshotSecret?.secrets?.length;
+ const isSecretEmpty = (!isRollbackMode && isDashboardSecretEmpty) || isSnapshotSecretEmtpy;
+
+ const userAvailableEnvs = wsEnv?.filter(
+ ({ isReadDenied, isWriteDenied }) => !isReadDenied || !isWriteDenied
+ );
+
+ return (
+
+
+
+ {/* Create a new tag modal */}
+ {
+ handlePopUpToggle('addTag', open);
+ }}
+ >
+
+
+
+
+ {/* Uploaded env override or not confirmation modal */}
+ handlePopUpToggle('uploadedSecOpts', open)}
+ >
+ handlePopUpClose('uploadedSecOpts')}
+ >
+ Keep old
+ ,
+
+ Overwrite
+
+ ]}
+ >
+
+
Your file contains following duplicate secrets
+
+ {Object.keys((popUp?.uploadedSecOpts?.data as TSecOverwriteOpt)?.secrets || {})
+ ?.map((key) => key)
+ .join(', ')}
+
+
Are you sure you want to overwrite these secrets?
+
+
+
+ handlePopUpToggle('compareSecrets', open)}
+ >
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/views/DashboardPage/DashboardPage.utils.ts b/frontend/src/views/DashboardPage/DashboardPage.utils.ts
new file mode 100644
index 000000000..ef754d1c5
--- /dev/null
+++ b/frontend/src/views/DashboardPage/DashboardPage.utils.ts
@@ -0,0 +1,231 @@
+import crypto from 'crypto';
+
+import * as yup from 'yup';
+
+import {
+ decryptAssymmetric,
+ encryptSymmetric
+} from '@app/components/utilities/cryptography/crypto';
+import { BatchSecretDTO } from '@app/hooks/api/secrets/types';
+
+export enum SecretActionType {
+ Created = 'created',
+ Modified = 'modified',
+ Deleted = 'deleted'
+}
+
+export const DEFAULT_SECRET_VALUE = {
+ _id: undefined,
+ overrideAction: undefined,
+ idOverride: undefined,
+ valueOverride: undefined,
+ comment: '',
+ key: '',
+ value: '',
+ tags: []
+};
+
+const secretSchema = yup.object({
+ _id: yup.string(),
+ key: yup
+ .string()
+ .trim()
+ .required()
+ .label('Secret key')
+ .test('starts-with-number', 'Should start with an alphabet', (val) =>
+ Boolean(val?.charAt(0)?.match(/[a-zA-Z]/i))
+ )
+ .test({
+ name: 'duplicate-keys',
+ // TODO:(akhilmhdh) ts keeps throwing from not found need to see how to resolve this
+ test: (val, ctx: any) => {
+ const secrets: Array<{ key: string }> = ctx?.from?.[1]?.value?.secrets || [];
+ const duplicateKeys: Record = {};
+ secrets?.forEach(({ key }, index) => {
+ if (key === val) duplicateKeys[index + 1] = true;
+ });
+ const pos = Object.keys(duplicateKeys);
+ if (pos.length <= 1) {
+ return true;
+ }
+ return ctx.createError({ message: `Same key in row ${pos.join(', ')}` });
+ }
+ }),
+ value: yup.string().trim(),
+ comment: yup.string().trim(),
+ tags: yup.array(
+ yup.object({
+ _id: yup.string().required(),
+ name: yup.string().required(),
+ slug: yup.string().required()
+ })
+ ),
+ overrideAction: yup.string().notRequired().oneOf(Object.values(SecretActionType)),
+ idOverride: yup.string().notRequired(),
+ valueOverride: yup.string().trim().notRequired()
+});
+
+export const schema = yup.object({
+ isSnapshotMode: yup.bool().notRequired(),
+ secrets: yup.array(secretSchema)
+});
+
+export type FormData = yup.InferType;
+export type TSecretDetailsOpen = { index: number; id: string };
+export type TSecOverwriteOpt = { secrets: Record };
+
+export const downloadSecret = (secrets: FormData['secrets'] = [], env: string = 'unknown') => {
+ const finalSecret = secrets.map(({ key, value, valueOverride, overrideAction, comment }) => ({
+ key,
+ value: overrideAction && overrideAction !== SecretActionType.Deleted ? valueOverride : value,
+ comment
+ }));
+
+ let file = '';
+ finalSecret.forEach(({ key, value, comment }) => {
+ if (comment) {
+ file += `# ${comment}\n${key}=${value}\n`;
+ return;
+ }
+ file += `${key}=${value}\n`;
+ });
+
+ const blob = new Blob([file]);
+ const fileDownloadUrl = URL.createObjectURL(blob);
+ const alink = document.createElement('a');
+ alink.href = fileDownloadUrl;
+ alink.download = `${env}.env`;
+ alink.click();
+};
+
+/*
+ * Below functions are used convert the dashboard secrets to the bulk secret creation request format
+ * They are encrypted back
+ * Formatted to [ { request: "", secret:{} } ]
+ */
+const encryptASecret = (randomBytes: string, key: string, value?: string, comment?: string) => {
+ // encrypt key
+ const {
+ ciphertext: secretKeyCiphertext,
+ iv: secretKeyIV,
+ tag: secretKeyTag
+ } = encryptSymmetric({
+ plaintext: key,
+ key: randomBytes
+ });
+
+ // encrypt value
+ const {
+ ciphertext: secretValueCiphertext,
+ iv: secretValueIV,
+ tag: secretValueTag
+ } = encryptSymmetric({
+ plaintext: value ?? '',
+ key: randomBytes
+ });
+
+ // encrypt comment
+ const {
+ ciphertext: secretCommentCiphertext,
+ iv: secretCommentIV,
+ tag: secretCommentTag
+ } = encryptSymmetric({
+ plaintext: comment ?? '',
+ key: randomBytes
+ });
+
+ return {
+ secretKeyCiphertext,
+ secretKeyIV,
+ secretKeyTag,
+ secretValueCiphertext,
+ secretValueIV,
+ secretValueTag,
+ secretCommentCiphertext,
+ secretCommentIV,
+ secretCommentTag
+ };
+};
+
+export const transformSecretsToBatchSecretReq = (
+ deletedSecretIds: string[],
+ latestFileKey: any,
+ secrets: FormData['secrets']
+) => {
+ // deleted secrets
+ const secretsToBeDeleted: BatchSecretDTO['requests'] = deletedSecretIds.map((id) => ({
+ method: 'DELETE',
+ secret: { _id: id }
+ }));
+
+ const secretsToBeUpdated: BatchSecretDTO['requests'] = [];
+ const secretsToBeCreated: BatchSecretDTO['requests'] = [];
+ const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string;
+
+ const randomBytes = latestFileKey
+ ? decryptAssymmetric({
+ ciphertext: latestFileKey.encryptedKey,
+ nonce: latestFileKey.nonce,
+ publicKey: latestFileKey.sender.publicKey,
+ privateKey: PRIVATE_KEY
+ })
+ : crypto.randomBytes(16).toString('hex');
+
+ secrets?.forEach(
+ ({ _id, idOverride, value, valueOverride, overrideAction, tags = [], comment, key }) => {
+ if (!idOverride && overrideAction === SecretActionType.Created) {
+ secretsToBeCreated.push({
+ method: 'POST',
+ secret: {
+ type: 'personal',
+ tags,
+ ...encryptASecret(randomBytes, key, valueOverride, comment)
+ }
+ });
+ }
+ // to be created ones as they don't have server generated id
+ if (!_id) {
+ secretsToBeCreated.push({
+ method: 'POST',
+ secret: {
+ type: 'shared',
+ tags,
+ ...encryptASecret(randomBytes, key, value, comment)
+ }
+ });
+ return; // exit as updated and delete case won't happen when created
+ }
+ // has an id means this is updated one
+ if (_id) {
+ secretsToBeUpdated.push({
+ method: 'PATCH',
+ secret: {
+ _id,
+ type: 'shared',
+ tags,
+ ...encryptASecret(randomBytes, key, value, comment)
+ }
+ });
+ }
+ if (idOverride) {
+ // if action is deleted meaning override has been removed but id is kept to collect at this point
+ if (overrideAction === SecretActionType.Deleted) {
+ secretsToBeDeleted.push({ method: 'DELETE', secret: { _id: idOverride } });
+ } else {
+ // if not deleted action then as id is there its an updated
+ secretsToBeUpdated.push({
+ method: 'PATCH',
+ secret: {
+ _id: idOverride,
+ type: 'personal',
+ tags,
+ ...encryptASecret(randomBytes, key, valueOverride, comment)
+ }
+ });
+ }
+ }
+ }
+ );
+
+ return secretsToBeCreated.concat(secretsToBeUpdated, secretsToBeDeleted);
+};
diff --git a/frontend/src/views/DashboardPage/components/CompareSecret/CompareSecret.tsx b/frontend/src/views/DashboardPage/components/CompareSecret/CompareSecret.tsx
new file mode 100644
index 000000000..9075595aa
--- /dev/null
+++ b/frontend/src/views/DashboardPage/components/CompareSecret/CompareSecret.tsx
@@ -0,0 +1,64 @@
+import { useCallback } from 'react';
+
+import { FormControl, Input, Spinner } from '@app/components/v2';
+import { useGetProjectSecrets, useGetUserWsKey } from '@app/hooks/api';
+
+type SecretValueProps = {
+ workspaceId: string;
+ envName: string;
+ env: string;
+ secretKey: string;
+};
+
+const SecretValue = ({ workspaceId, env, envName, secretKey }: SecretValueProps) => {
+ const { data: latestFileKey } = useGetUserWsKey(workspaceId);
+ const { data: secret, isLoading: isSecretsLoading } = useGetProjectSecrets({
+ workspaceId,
+ env,
+ decryptFileKey: latestFileKey!
+ });
+
+ const getValue = useCallback(
+ (data: typeof secret) => {
+ const sec = data?.secrets?.find(({ key: secKey }) => secKey === secretKey);
+ return sec?.value || 'Not found';
+ },
+ [secretKey]
+ );
+
+ return (
+
+ : undefined}
+ />
+
+ );
+};
+
+type Props = {
+ workspaceId: string;
+ secretKey: string;
+ envs: Array<{ name: string; slug: string }>;
+};
+
+export const CompareSecret = ({ workspaceId, secretKey, envs }: Props): JSX.Element => {
+ // should not do anything until secretKey is available
+ if (!secretKey) return
;
+
+ return (
+
+ {envs.map(({ name, slug }) => (
+
+ ))}
+
+ );
+};
diff --git a/frontend/src/views/DashboardPage/components/CompareSecret/index.tsx b/frontend/src/views/DashboardPage/components/CompareSecret/index.tsx
new file mode 100644
index 000000000..871a6529d
--- /dev/null
+++ b/frontend/src/views/DashboardPage/components/CompareSecret/index.tsx
@@ -0,0 +1 @@
+export { CompareSecret } from './CompareSecret';
diff --git a/frontend/src/views/DashboardPage/components/CreateTagModal/CreateTagModal.tsx b/frontend/src/views/DashboardPage/components/CreateTagModal/CreateTagModal.tsx
new file mode 100644
index 000000000..541417cbc
--- /dev/null
+++ b/frontend/src/views/DashboardPage/components/CreateTagModal/CreateTagModal.tsx
@@ -0,0 +1,55 @@
+import { Controller, useForm } from 'react-hook-form';
+import { yupResolver } from '@hookform/resolvers/yup';
+import * as yup from 'yup';
+
+import { Button, FormControl, Input, ModalClose } from '@app/components/v2';
+
+type Props = {
+ onCreateTag: (tagName: string) => Promise;
+};
+
+const createTagSchema = yup.object({
+ name: yup.string().required().trim().label('Tag Name')
+});
+type FormData = yup.InferType;
+
+export const CreateTagModal = ({ onCreateTag }: Props): JSX.Element => {
+ const {
+ control,
+ reset,
+ formState: { isSubmitting },
+ handleSubmit
+ } = useForm({
+ resolver: yupResolver(createTagSchema)
+ });
+
+ const onFormSubmit = async ({ name }: FormData) => {
+ await onCreateTag(name);
+ reset();
+ };
+
+ return (
+
+ );
+};
diff --git a/frontend/src/views/DashboardPage/components/CreateTagModal/index.tsx b/frontend/src/views/DashboardPage/components/CreateTagModal/index.tsx
new file mode 100644
index 000000000..a660c64fd
--- /dev/null
+++ b/frontend/src/views/DashboardPage/components/CreateTagModal/index.tsx
@@ -0,0 +1 @@
+export {CreateTagModal} from './CreateTagModal'
\ No newline at end of file
diff --git a/frontend/src/views/DashboardPage/components/EnvComparisonRow/EnvComparisonRow.tsx b/frontend/src/views/DashboardPage/components/EnvComparisonRow/EnvComparisonRow.tsx
new file mode 100644
index 000000000..acadf600d
--- /dev/null
+++ b/frontend/src/views/DashboardPage/components/EnvComparisonRow/EnvComparisonRow.tsx
@@ -0,0 +1,131 @@
+/* eslint-disable react/jsx-no-useless-fragment */
+import { SyntheticEvent, useRef, useState } from 'react';
+import { useFormContext, useWatch } from 'react-hook-form';
+import { faCircle, faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+
+import guidGenerator from '@app/components/utilities/randomId';
+
+import { FormData } from '../../DashboardPage.utils';
+
+type Props = {
+ index: number;
+ secrets: any[] | undefined;
+ // permission and external state's that decided to hide or show
+ isReadOnly?: boolean;
+ isSecretValueHidden: boolean;
+ userAvailableEnvs?: any[];
+};
+
+const REGEX = /([$]{.*?})/g;
+
+const DashboardInput = ({ isOverridden, isSecretValueHidden, isReadOnly, secret, index }: { isOverridden: boolean, isSecretValueHidden: boolean, isReadOnly?: boolean, secret: any, index: number } ): JSX.Element => {
+ 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 || ''}
+ setAreValuesHiddenThisRow(!areValuesHiddenThisRow)}>
+
+
+
+ {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/PitDrawer/PitDrawer.tsx b/frontend/src/views/DashboardPage/components/PitDrawer/PitDrawer.tsx
new file mode 100644
index 000000000..a398530b2
--- /dev/null
+++ b/frontend/src/views/DashboardPage/components/PitDrawer/PitDrawer.tsx
@@ -0,0 +1,83 @@
+import { Fragment, useCallback } from 'react';
+import { InfiniteData } from '@tanstack/react-query';
+
+import { Button, Drawer, DrawerContent } from '@app/components/v2';
+import timeSince from '@app/ee/utilities/timeSince';
+import { TWorkspaceSecretSnapshot } from '@app/hooks/api/secretSnapshots/types';
+
+type Props = {
+ isDrawerOpen: boolean;
+ onOpenChange: (isOpen: boolean) => void;
+ secretSnaphots?: InfiniteData;
+ onSelectSnapshot: (id: string) => void;
+ snapshotId: string | null;
+ isFetchingNextPage?: boolean;
+ hasNextPage?: boolean;
+ fetchNextPage: () => void;
+};
+
+export const PitDrawer = ({
+ isDrawerOpen,
+ onOpenChange,
+ secretSnaphots,
+ onSelectSnapshot,
+ snapshotId,
+ isFetchingNextPage,
+ hasNextPage,
+ fetchNextPage
+}: Props): JSX.Element => {
+ const getButtonLabel = useCallback((isFirstChild: boolean, isSelectedSnapshot: boolean) => {
+ if (isFirstChild) return 'Current Version';
+ if (isSelectedSnapshot) return 'Currently Viewing';
+ return 'Explore';
+ }, []);
+
+ return (
+
+
+
+ {secretSnaphots?.pages?.map((group, i) => (
+
+ {group.map(({ _id, createdAt }, index) => (
+ onSelectSnapshot(_id)}
+ >
+
+
{timeSince(new Date(createdAt))}
+
{getButtonLabel(i === 0 && index === 0, snapshotId === _id)}
+
+
+ ))}
+
+ ))}
+
+
+ {hasNextPage ? 'Load More' : 'End of history'}
+
+
+
+ );
+};
diff --git a/frontend/src/views/DashboardPage/components/PitDrawer/index.tsx b/frontend/src/views/DashboardPage/components/PitDrawer/index.tsx
new file mode 100644
index 000000000..993b641ff
--- /dev/null
+++ b/frontend/src/views/DashboardPage/components/PitDrawer/index.tsx
@@ -0,0 +1 @@
+export { PitDrawer } from './PitDrawer';
diff --git a/frontend/src/views/DashboardPage/components/SecretDetailDrawer/GenRandomNumber.tsx b/frontend/src/views/DashboardPage/components/SecretDetailDrawer/GenRandomNumber.tsx
new file mode 100644
index 000000000..d64a6ad36
--- /dev/null
+++ b/frontend/src/views/DashboardPage/components/SecretDetailDrawer/GenRandomNumber.tsx
@@ -0,0 +1,52 @@
+import crypto from 'crypto';
+
+import { useState } from 'react';
+import { useTranslation } from 'next-i18next';
+import { faMinus, faPlus, faShuffle } from '@fortawesome/free-solid-svg-icons';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+
+import { Button, Input } from '@app/components/v2';
+
+type Props = {
+ onGenerate: (val: string) => void;
+};
+
+export const GenRandomNumber = ({ onGenerate }: Props) => {
+ const { t } = useTranslation();
+ const [value, setValue] = useState(32);
+
+ const onGenerateRandomHex = () => {
+ const rand = crypto.randomBytes(value).toString('hex');
+ onGenerate(rand);
+ };
+
+ return (
+
+
}
+ onClick={onGenerateRandomHex}
+ >
+ {t('dashboard:sidebar.generate-random-hex')}
+
+
+
+ setValue((val) => val - 1)}>
+
+
+ setValue(parseInt(e.target.value, 10))}
+ />
+ setValue((val) => val + 1)}>
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/views/DashboardPage/components/SecretDetailDrawer/SecretDetailDrawer.tsx b/frontend/src/views/DashboardPage/components/SecretDetailDrawer/SecretDetailDrawer.tsx
new file mode 100644
index 000000000..f0d64819f
--- /dev/null
+++ b/frontend/src/views/DashboardPage/components/SecretDetailDrawer/SecretDetailDrawer.tsx
@@ -0,0 +1,211 @@
+import { useFormContext } from 'react-hook-form';
+import { faCircle, faCircleDot, faShuffle } from '@fortawesome/free-solid-svg-icons';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+
+import {
+ Button,
+ Drawer,
+ DrawerContent,
+ FormControl,
+ Input,
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+ Switch,
+ TextArea
+} from '@app/components/v2';
+import { useToggle } from '@app/hooks';
+
+import { FormData, SecretActionType } from '../../DashboardPage.utils';
+import { GenRandomNumber } from './GenRandomNumber';
+
+type Props = {
+ isDrawerOpen: boolean;
+ onOpenChange: (isOpen: boolean) => void;
+ index: number;
+ isReadOnly?: boolean;
+ onEnvCompare: (secretKey: string) => void;
+ secretVersion?: Array<{ id: string; createdAt: string; value: string }>;
+ // to record the ids of deleted ones
+ onSecretDelete: (index: number, id?: string, overrideId?: string) => void;
+ onSave: () => void;
+};
+
+export const SecretDetailDrawer = ({
+ isDrawerOpen,
+ onOpenChange,
+ index,
+ secretVersion = [],
+ isReadOnly,
+ onSecretDelete,
+ onSave,
+ onEnvCompare
+}: Props): JSX.Element => {
+ const [canRevealSecVal, setCanRevealSecVal] = useToggle();
+ const [canRevealSecOverride, setCanRevealSecOverride] = useToggle();
+
+ const { register, setValue, watch } = useFormContext();
+ const secret = watch(`secrets.${index}`);
+
+ const isOverridden =
+ secret?.overrideAction === SecretActionType.Created ||
+ secret?.overrideAction === SecretActionType.Modified;
+
+ const onSecretOverride = () => {
+ if (isOverridden) {
+ // when user created a new override but then removes
+ if (SecretActionType.Created) {
+ setValue(`secrets.${index}.valueOverride`, '', { shouldDirty: true });
+ }
+ setValue(`secrets.${index}.overrideAction`, SecretActionType.Deleted, { shouldDirty: true });
+ } else {
+ setValue(
+ `secrets.${index}.overrideAction`,
+ secret?.idOverride ? SecretActionType.Modified : SecretActionType.Created,
+ { shouldDirty: true }
+ );
+ }
+ };
+
+ if (!secret) {
+ return
;
+ }
+
+ return (
+
+
+
+ onEnvCompare(secret?.key)}
+ isFullWidth
+ isDisabled={isReadOnly}
+ >
+ Compare secret across environments
+
+
+
+
+ Save Changes
+
+ onSecretDelete(index, secret._id, secret.idOverride)}
+ >
+ Delete
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+ setValue(`secrets.${index}.value`, val, { shouldDirty: true })
+ }
+ />
+
+
+
+
+
+ Override with a personal value
+
+
+
+
+
+
+
+ }
+ />
+
+
+ setValue(`secrets.${index}.valueOverride`, val, { shouldDirty: true })
+ }
+ />
+
+
+
+
+
Version History
+
+ {secretVersion?.map(({ createdAt, value, id }, i) => (
+
+
+
+
+
+
+ {new Date(createdAt).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit'
+ })}
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/views/DashboardPage/components/SecretDetailDrawer/index.tsx b/frontend/src/views/DashboardPage/components/SecretDetailDrawer/index.tsx
new file mode 100644
index 000000000..3b93bffd6
--- /dev/null
+++ b/frontend/src/views/DashboardPage/components/SecretDetailDrawer/index.tsx
@@ -0,0 +1 @@
+export {SecretDetailDrawer} from './SecretDetailDrawer'
\ No newline at end of file
diff --git a/frontend/src/views/DashboardPage/components/SecretDropzone/SecretDropzone.tsx b/frontend/src/views/DashboardPage/components/SecretDropzone/SecretDropzone.tsx
new file mode 100644
index 000000000..e3a871819
--- /dev/null
+++ b/frontend/src/views/DashboardPage/components/SecretDropzone/SecretDropzone.tsx
@@ -0,0 +1,130 @@
+import { ChangeEvent, DragEvent } from 'react';
+import { useTranslation } from 'next-i18next';
+import { faUpload } from '@fortawesome/free-solid-svg-icons';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { twMerge } from 'tailwind-merge';
+
+import { useNotificationContext } from '@app/components/context/Notifications/NotificationProvider';
+// TODO:(akhilmhdh) convert all the util functions like this into a lib folder grouped by functionalityj
+import { parseDotEnv } from '@app/components/utilities/parseDotEnv';
+import { Button } from '@app/components/v2';
+import { useToggle } from '@app/hooks/useToggle';
+
+type Props = {
+ isSmaller: boolean;
+ onParsedEnv: (env: Record) => void;
+ onAddNewSecret?: () => void;
+};
+
+export const SecretDropzone = ({ isSmaller, onParsedEnv, onAddNewSecret }: Props): JSX.Element => {
+ const { t } = useTranslation();
+ const [isDragActive, setDragActive] = useToggle();
+ const [isLoading, setIsLoading] = useToggle();
+ const { createNotification } = useNotificationContext();
+
+ const handleDrag = (e: DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.type === 'dragenter' || e.type === 'dragover') {
+ setDragActive.on();
+ } else if (e.type === 'dragleave') {
+ setDragActive.off();
+ }
+ };
+
+ const parseFile = (file?: File) => {
+ const reader = new FileReader();
+ if (!file) {
+ createNotification({
+ text: `You can't inject files from VS Code. Click 'Reveal in finder', and drag your file directly from the directory where it's located.`,
+ type: 'error',
+ timeoutMs: 10000
+ });
+ return;
+ }
+ // const fileType = file.name.split('.')[1];
+ setIsLoading.on();
+ reader.onload = (event) => {
+ if (!event?.target?.result) return;
+ // parse function's argument looks like to be ArrayBuffer
+ const env = parseDotEnv(event.target.result as ArrayBuffer);
+ setIsLoading.off();
+ onParsedEnv(env);
+ };
+
+ // If something is wrong show an error
+ try {
+ reader.readAsText(file);
+ } catch (error) {
+ console.log(error);
+ }
+ };
+
+ const handleDrop = (e: DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (!e.dataTransfer) {
+ return;
+ }
+
+ e.dataTransfer.dropEffect = 'copy';
+ setDragActive.off();
+ parseFile(e.dataTransfer.files[0]);
+ };
+
+ const handleFileUpload = (e: ChangeEvent) => {
+ e.preventDefault();
+ parseFile(e.target?.files?.[0]);
+ };
+
+ return (
+
+ {isLoading ? (
+
+
+
+ ) : (
+ <>
+
+
+
+
+
{t(isSmaller ? 'common:drop-zone-keys' : 'common:drop-zone')}
+
+
+ {!isSmaller && (
+ <>
+
+
+
+ Add a new secret
+
+
+ >
+ )}{' '}
+ >
+ )}
+
+ );
+};
diff --git a/frontend/src/views/DashboardPage/components/SecretDropzone/index.tsx b/frontend/src/views/DashboardPage/components/SecretDropzone/index.tsx
new file mode 100644
index 000000000..dc1752390
--- /dev/null
+++ b/frontend/src/views/DashboardPage/components/SecretDropzone/index.tsx
@@ -0,0 +1 @@
+export { SecretDropzone } from './SecretDropzone';
diff --git a/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx b/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx
new file mode 100644
index 000000000..f0b2cd092
--- /dev/null
+++ b/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx
@@ -0,0 +1,424 @@
+/* eslint-disable react/jsx-no-useless-fragment */
+import { SyntheticEvent, useRef } from 'react';
+import { Controller, useFieldArray, useFormContext, useWatch } from 'react-hook-form';
+import {
+ faCircle,
+ faCodeBranch,
+ faComment,
+ faEllipsis,
+ faInfoCircle,
+ faPlus,
+ faTags,
+ faXmark
+} from '@fortawesome/free-solid-svg-icons';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { cx } from 'cva';
+import { twMerge } from 'tailwind-merge';
+
+import guidGenerator from '@app/components/utilities/randomId';
+import {
+ Button,
+ Checkbox,
+ FormControl,
+ HoverCard,
+ HoverCardContent,
+ HoverCardTrigger,
+ IconButton,
+ Input,
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+ Tag,
+ TextArea,
+ Tooltip
+} from '@app/components/v2';
+import { useToggle } from '@app/hooks';
+import { WsTag } from '@app/hooks/api/types';
+
+import { FormData, SecretActionType } from '../../DashboardPage.utils';
+
+type Props = {
+ index: number;
+ // permission and external state's that decided to hide or show
+ isReadOnly?: boolean;
+ isAddOnly?: boolean;
+ isRollbackMode?: boolean;
+ isSecretValueHidden: boolean;
+ searchTerm: string;
+ // to record the ids of deleted ones
+ onSecretDelete: (index: number, id?: string, overrideId?: string) => void;
+ // sidebar control props
+ onRowExpand: () => void;
+ // tag props
+ wsTags?: WsTag[];
+ onCreateTagOpen: () => void;
+};
+
+const tagColors = [
+ { bg: 'bg-[#f1c40f]/40', text: 'text-[#fcf0c3]/70' },
+ { bg: 'bg-[#cb1c8d]/40', text: 'text-[#f2c6e3]/70' },
+ { bg: 'bg-[#badc58]/40', text: 'text-[#eef6d5]/70' },
+ { bg: 'bg-[#ff5400]/40', text: 'text-[#ffddcc]/70' },
+ { bg: 'bg-[#3AB0FF]/40', text: 'text-[#f0fffd]/70' },
+ { bg: 'bg-[#6F1AB6]/40', text: 'text-[#FFE5F1]/70' },
+ { bg: 'bg-[#C40B13]/40', text: 'text-[#FFDEDE]/70' },
+ { bg: 'bg-[#332FD0]/40', text: 'text-[#DFF6FF]/70' }
+];
+const REGEX = /([$]{.*?})/g;
+
+export const SecretInputRow = ({
+ index,
+ isSecretValueHidden,
+ onRowExpand,
+ isReadOnly,
+ isRollbackMode,
+ isAddOnly,
+ wsTags,
+ onCreateTagOpen,
+ onSecretDelete,
+ searchTerm
+}: Props): JSX.Element => {
+ 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;
+ };
+ const { register, setValue, control } = useFormContext();
+ const [canRevealSecret] = useToggle();
+ // comment management in a row
+ const {
+ fields: secretTags,
+ remove,
+ append
+ } = useFieldArray({ control, name: `secrets.${index}.tags` });
+
+ // to get details on a secret
+ const secret = useWatch({ name: `secrets.${index}`, control });
+ const hasComment = Boolean(secret.comment);
+ const tags = secret.tags || [];
+ const selectedTagIds = tags.reduce>(
+ (prev, curr) => ({ ...prev, [curr.slug]: true }),
+ {}
+ );
+
+ // when secret is override by personal values
+ const isOverridden =
+ secret.overrideAction === SecretActionType.Created ||
+ secret.overrideAction === SecretActionType.Modified;
+
+ const onSecretOverride = () => {
+ if (isOverridden) {
+ // when user created a new override but then removes
+ if (secret?.overrideAction === SecretActionType.Created)
+ setValue(`secrets.${index}.valueOverride`, '');
+ setValue(`secrets.${index}.overrideAction`, SecretActionType.Deleted, { shouldDirty: true });
+ } else {
+ setValue(`secrets.${index}.valueOverride`, '');
+ setValue(
+ `secrets.${index}.overrideAction`,
+ secret?.idOverride ? SecretActionType.Modified : SecretActionType.Created,
+ { shouldDirty: true }
+ );
+ }
+ };
+
+ const onSelectTag = (selectedTag: WsTag) => {
+ const shouldAppend = !selectedTagIds[selectedTag.slug];
+ if (shouldAppend) {
+ append(selectedTag);
+ } else {
+ const pos = tags.findIndex(({ slug }) => selectedTag.slug === slug);
+ remove(pos);
+ }
+ };
+
+ const isCreatedSecret = !secret?._id;
+ const shouldBeBlockedInAddOnly = !isCreatedSecret && isAddOnly;
+
+ // Why this instead of filter in parent
+ // Because rhf field.map has default values so basically
+ // keys are not updated there and index needs to kept so that we can monitor
+ // values individually here
+ if (
+ !(
+ secret.key?.toUpperCase().includes(searchTerm?.toUpperCase()) ||
+ tags
+ ?.map((tag) => tag.name)
+ .join(' ')
+ ?.toUpperCase()
+ .includes(searchTerm?.toUpperCase()) ||
+ secret.comment?.toUpperCase().includes(searchTerm?.toUpperCase())
+ )
+ ) {
+ return <>>;
+ }
+
+ return (
+
+ {index + 1}
+ (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!isAddOnly && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
{error?.message}
+
+
+
+ )}
+ />
+
+
+ {isOverridden
+ ?
+ :
}
+
+ {(isOverridden ? secret.valueOverride : secret.value)?.split('').length === 0 && EMPTY }
+ {(isOverridden ? secret.valueOverride : secret.value)?.split(REGEX).map((word) => {
+ 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}
+
+ );
+ })}
+
+ {(!canRevealSecret && isSecretValueHidden) && (
+
+
+ {(isOverridden ? secret.valueOverride : secret.value)?.split('').map(() => (
+
+ ))}
+ {(isOverridden ? secret.valueOverride : secret.value)?.split('').length === 0 && EMPTY }
+
+
+ )}
+
+
+
+
+ {secretTags.map(({ id, slug }, i) => (
+
remove(i)}
+ key={id}
+ >
+ {slug}
+
+ ))}
+ {!(isReadOnly || isAddOnly || isRollbackMode) && (
+
+
+
+
+
+
+
+
+
+
+
+
+ Add tags to {secret.key || "this secret"}
+
+ {wsTags?.map((wsTag) => (
+ onSelectTag(wsTag)}
+ leftIcon={
+ {}}
+ >
+ {}
+
+ }
+ key={wsTag._id}
+ >
+ {wsTag.slug}
+
+ ))}
+ }
+ >
+ Add new tag
+
+
+
+
+
+ )}
+
+
+ {!isAddOnly && (
+
+
+
+
+
+
+
+ )}
+
+
+ onSecretDelete(index, secret._id, secret?.idOverride)}
+ >
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/views/DashboardPage/components/SecretInputRow/index.tsx b/frontend/src/views/DashboardPage/components/SecretInputRow/index.tsx
new file mode 100644
index 000000000..94701da38
--- /dev/null
+++ b/frontend/src/views/DashboardPage/components/SecretInputRow/index.tsx
@@ -0,0 +1 @@
+export { SecretInputRow } from './SecretInputRow';
diff --git a/frontend/src/views/DashboardPage/components/SecretTableHeader/SecretTableHeader.tsx b/frontend/src/views/DashboardPage/components/SecretTableHeader/SecretTableHeader.tsx
new file mode 100644
index 000000000..2c7631298
--- /dev/null
+++ b/frontend/src/views/DashboardPage/components/SecretTableHeader/SecretTableHeader.tsx
@@ -0,0 +1,44 @@
+import { Controller } from 'react-hook-form';
+import { faArrowDown, faArrowUp } from '@fortawesome/free-solid-svg-icons';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+
+import { IconButton } from '@app/components/v2';
+
+type Props = {
+ sortDir: 'asc' | 'desc';
+ onSort: () => void;
+};
+
+export const SecretTableHeader = ({
+ sortDir,
+ onSort
+}: Props): JSX.Element => (
+
+
+
+ {0}
+
+ (
+
+
+
+ )}
+ />
+ Value
+
+
+
+);
diff --git a/frontend/src/views/DashboardPage/components/SecretTableHeader/index.tsx b/frontend/src/views/DashboardPage/components/SecretTableHeader/index.tsx
new file mode 100644
index 000000000..ad0332905
--- /dev/null
+++ b/frontend/src/views/DashboardPage/components/SecretTableHeader/index.tsx
@@ -0,0 +1 @@
+export { SecretTableHeader } from './SecretTableHeader';
diff --git a/frontend/src/views/DashboardPage/index.tsx b/frontend/src/views/DashboardPage/index.tsx
new file mode 100644
index 000000000..d1e7bf228
--- /dev/null
+++ b/frontend/src/views/DashboardPage/index.tsx
@@ -0,0 +1 @@
+export { DashboardPage } from './DashboardPage';
diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/CreateServiceAccountPage.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/CreateServiceAccountPage.tsx
new file mode 100644
index 000000000..56914f7df
--- /dev/null
+++ b/frontend/src/views/Settings/CreateServiceAccountPage/CreateServiceAccountPage.tsx
@@ -0,0 +1,46 @@
+import { useRouter } from 'next/router';
+
+import NavHeader from '@app/components/navigation/NavHeader';
+
+import { SAProjectLevelPermissionsTable } from './components/SAProjectLevelPermissionsTable';
+import {
+ CopyServiceAccountPublicKeySection,
+ ServiceAccountNameChangeSection
+} from './components';
+
+export const CreateServiceAccountPage = () => {
+ const router = useRouter();
+ const {serviceAccountId} = router.query;
+
+ return (
+
+
+
+
Service Account
+
+ A service account represents a machine identity such as a VM or application client.
+
+
+ {typeof serviceAccountId === 'string' && (
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/components/CopyServiceAccountIDSection/CopyServiceAccountIDSection.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/components/CopyServiceAccountIDSection/CopyServiceAccountIDSection.tsx
new file mode 100644
index 000000000..768145b09
--- /dev/null
+++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/CopyServiceAccountIDSection/CopyServiceAccountIDSection.tsx
@@ -0,0 +1,49 @@
+import { useEffect } from 'react';
+import { faCheck, faCopy } from '@fortawesome/free-solid-svg-icons';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+
+import { IconButton } from '@app/components/v2';
+import { useToggle } from '@app/hooks';
+
+type Props = {
+ serviceAccountId: string;
+}
+
+export const CopyServiceAccountIDSection = ({ serviceAccountId }: Props): JSX.Element => {
+ const [isServiceAccountIdCopied, setIsServiceAccountIdCopied] = useToggle(false);
+
+ useEffect(() => {
+ let timer: NodeJS.Timeout;
+
+ if (isServiceAccountIdCopied) {
+ timer = setTimeout(() => setIsServiceAccountIdCopied.off(), 2000);
+ }
+
+ return () => clearTimeout(timer);
+ }, [isServiceAccountIdCopied]);
+
+ const copyServiceAccountIdToClipboard = () => {
+ navigator.clipboard.writeText(serviceAccountId);
+ setIsServiceAccountIdCopied.on();
+ };
+
+ return (
+
+
Service Account ID
+
+
{serviceAccountId}
+
copyServiceAccountIdToClipboard()}
+ >
+
+
+ Copy
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/components/CopyServiceAccountIDSection/index.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/components/CopyServiceAccountIDSection/index.tsx
new file mode 100644
index 000000000..01cae04d6
--- /dev/null
+++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/CopyServiceAccountIDSection/index.tsx
@@ -0,0 +1 @@
+export { CopyServiceAccountIDSection } from './CopyServiceAccountIDSection';
\ No newline at end of file
diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/components/CopyServiceAccountPublicKeySection/CopyServiceAccountPublicKeySection.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/components/CopyServiceAccountPublicKeySection/CopyServiceAccountPublicKeySection.tsx
new file mode 100644
index 000000000..e12001985
--- /dev/null
+++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/CopyServiceAccountPublicKeySection/CopyServiceAccountPublicKeySection.tsx
@@ -0,0 +1,53 @@
+import { useEffect } from 'react';
+import { faCheck, faCopy } from '@fortawesome/free-solid-svg-icons';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+
+import { IconButton } from '@app/components/v2';
+import { useToggle } from '@app/hooks';
+import { useGetServiceAccountById } from '@app/hooks/api';
+
+type Props = {
+ serviceAccountId: string;
+}
+
+export const CopyServiceAccountPublicKeySection = ({ serviceAccountId }: Props): JSX.Element => {
+ const { data: serviceAccount } = useGetServiceAccountById(serviceAccountId);
+ const [isServiceAccountIdCopied, setIsServiceAccountIdCopied] = useToggle(false);
+
+ useEffect(() => {
+ let timer: NodeJS.Timeout;
+
+ if (isServiceAccountIdCopied) {
+ timer = setTimeout(() => setIsServiceAccountIdCopied.off(), 2000);
+ }
+
+ return () => clearTimeout(timer);
+ }, [isServiceAccountIdCopied]);
+
+ const copyServiceAccountIdToClipboard = () => {
+ if (!serviceAccount) return;
+
+ navigator.clipboard.writeText(serviceAccount.publicKey);
+ setIsServiceAccountIdCopied.on();
+ };
+
+ return serviceAccount ? (
+
+
Public Key
+
+
{serviceAccount.publicKey}
+
copyServiceAccountIdToClipboard()}
+ >
+
+
+ Copy
+
+
+
+
+ ) :
+}
\ No newline at end of file
diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/components/CopyServiceAccountPublicKeySection/index.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/components/CopyServiceAccountPublicKeySection/index.tsx
new file mode 100644
index 000000000..d9ed7f56f
--- /dev/null
+++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/CopyServiceAccountPublicKeySection/index.tsx
@@ -0,0 +1 @@
+export { CopyServiceAccountPublicKeySection } from './CopyServiceAccountPublicKeySection';
\ No newline at end of file
diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/SAProjectLevelPermissionsTable.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/SAProjectLevelPermissionsTable.tsx
new file mode 100644
index 000000000..170e71e7e
--- /dev/null
+++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/SAProjectLevelPermissionsTable.tsx
@@ -0,0 +1,407 @@
+import { 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 {
+ decryptAssymmetric,
+ encryptAssymmetric,
+ verifyPrivateKey} from '@app/components/utilities/cryptography/crypto';
+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 {
+ useCreateServiceAccountProjectLevelPermission,
+ useDeleteServiceAccountProjectLevelPermission,
+ useGetServiceAccountById,
+ useGetServiceAccountProjectLevelPermissions,
+ useGetUserWorkspaces
+} from '@app/hooks/api';
+import getLatestFileKey from '@app/pages/api/workspace/getLatestFileKey';
+
+const createProjectLevelPermissionSchema = yup.object({
+ privateKey: yup.string().required().label('Private Key'),
+ workspace: yup.string().required().label('Workspace'),
+ environment: yup.string().required().label('Environment'),
+ permissions: yup.object().shape({
+ read: yup.boolean().required(),
+ write: yup.boolean().required()
+ }).defined().required()
+});
+
+type CreateProjectLevelPermissionForm = yup.InferType;
+
+type Props = {
+ serviceAccountId: string;
+}
+
+export const SAProjectLevelPermissionsTable = ({
+ serviceAccountId
+}: Props): JSX.Element => {
+ const { data: serviceAccount } = useGetServiceAccountById(serviceAccountId);
+ const { data: userWorkspaces, isLoading: isUserWorkspacesLoading } = useGetUserWorkspaces();
+ const [searchPermissions, setSearchPermissions] = useState('');
+
+ const { data: serviceAccountWorkspacePermissions, isLoading: isPermissionsLoading } = useGetServiceAccountProjectLevelPermissions(serviceAccountId);
+
+ const createServiceAccountProjectLevelPermission = useCreateServiceAccountProjectLevelPermission();
+ const deleteServiceAccountProjectLevelPermission = useDeleteServiceAccountProjectLevelPermission();
+
+ const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
+ 'addProjectLevelPermission',
+ 'removeProjectLevelPermission',
+ ] as const);
+
+ const [, setSelectedWorkspace] = useState(undefined);
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ formState: { isSubmitting }
+ } = useForm({ resolver: yupResolver(createProjectLevelPermissionSchema) })
+
+ const onAddProjectLevelPermission = async ({
+ privateKey,
+ workspace,
+ environment,
+ permissions: { read, write }
+ }: CreateProjectLevelPermissionForm) => {
+
+ // TODO: clean up / modularize this function
+
+ if (!serviceAccount) return;
+
+ const { latestKey } = await getLatestFileKey({
+ workspaceId: workspace
+ });
+
+ verifyPrivateKey({
+ privateKey,
+ publicKey: serviceAccount.publicKey
+ });
+
+ const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string;
+
+ const key = decryptAssymmetric({
+ ciphertext: latestKey.encryptedKey,
+ nonce: latestKey.nonce,
+ publicKey: latestKey.sender.publicKey,
+ privateKey: PRIVATE_KEY
+ });
+
+ const { ciphertext, nonce } = encryptAssymmetric({
+ plaintext: key,
+ publicKey: serviceAccount.publicKey,
+ privateKey
+ });
+
+ await createServiceAccountProjectLevelPermission.mutateAsync({
+ serviceAccountId,
+ workspaceId: workspace,
+ environment,
+ read,
+ write,
+ encryptedKey: ciphertext,
+ nonce
+ });
+ handlePopUpClose('addProjectLevelPermission');
+ }
+
+ const onRemoveProjectLevelPermission = async () => {
+ const serviceAccountWorkspacePermissionId = (popUp?.removeProjectLevelPermission?.data as { _id: string })?._id;
+ await deleteServiceAccountProjectLevelPermission.mutateAsync({
+ serviceAccountId,
+ serviceAccountWorkspacePermissionId
+ });
+ handlePopUpClose('removeProjectLevelPermission');
+ }
+
+ return (
+
+
Project-Level Permissions
+
+
+ setSearchPermissions(e.target.value)}
+ leftIcon={ }
+ placeholder="Search service account project-level permissions..."
+ />
+
+
}
+ onClick={() => {
+ handlePopUpOpen('addProjectLevelPermission')
+ reset();
+ }}
+ >
+ Add Permission
+
+
+
+
+
+
+ Project
+ Environment
+ Read
+ Write
+
+
+
+
+ {isPermissionsLoading && }
+ {!isPermissionsLoading && serviceAccountWorkspacePermissions && (
+ serviceAccountWorkspacePermissions.map(({
+ _id,
+ workspace,
+ environment,
+ read,
+ write
+ }) => {
+ const environmentName = (workspace.environments.find((env) => env.slug === environment))?.name;
+ return (
+
+ {workspace.name}
+ {environmentName}
+
+ {/**/}
+
+
+ {/**/}
+
+
+ handlePopUpOpen('removeProjectLevelPermission', { _id })}
+ >
+
+
+
+
+ );
+ })
+ )}
+ {!isPermissionsLoading && serviceAccountWorkspacePermissions?.length === 0 && (
+
+
+
+
+
+ )}
+
+
+
+
{
+ handlePopUpToggle('addProjectLevelPermission', isOpen);
+ }}
+ >
+
+
+
+
+
handlePopUpToggle('removeProjectLevelPermission', isOpen)}
+ onDeleteApproved={onRemoveProjectLevelPermission}
+ />
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/index.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/index.tsx
new file mode 100644
index 000000000..164b60d51
--- /dev/null
+++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/index.tsx
@@ -0,0 +1 @@
+export { SAProjectLevelPermissionsTable } from './SAProjectLevelPermissionsTable';
\ No newline at end of file
diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/components/ServiceAccountNameChangeSection/ServiceAccountNameChangeSection.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/components/ServiceAccountNameChangeSection/ServiceAccountNameChangeSection.tsx
new file mode 100644
index 000000000..7bb7d3414
--- /dev/null
+++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/ServiceAccountNameChangeSection/ServiceAccountNameChangeSection.tsx
@@ -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;
+
+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({ 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 (
+
+ );
+}
diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/components/ServiceAccountNameChangeSection/index.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/components/ServiceAccountNameChangeSection/index.tsx
new file mode 100644
index 000000000..711dae779
--- /dev/null
+++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/ServiceAccountNameChangeSection/index.tsx
@@ -0,0 +1 @@
+export { ServiceAccountNameChangeSection } from './ServiceAccountNameChangeSection';
\ No newline at end of file
diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/components/index.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/components/index.tsx
new file mode 100644
index 000000000..141981735
--- /dev/null
+++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/index.tsx
@@ -0,0 +1,4 @@
+export { CopyServiceAccountIDSection } from './CopyServiceAccountIDSection';
+export { CopyServiceAccountPublicKeySection } from './CopyServiceAccountPublicKeySection';
+export { SAProjectLevelPermissionsTable } from './SAProjectLevelPermissionsTable';
+export { ServiceAccountNameChangeSection } from './ServiceAccountNameChangeSection';
\ No newline at end of file
diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/index.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/index.tsx
new file mode 100644
index 000000000..b308a6841
--- /dev/null
+++ b/frontend/src/views/Settings/CreateServiceAccountPage/index.tsx
@@ -0,0 +1 @@
+export { CreateServiceAccountPage } from './CreateServiceAccountPage';
\ No newline at end of file
diff --git a/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx b/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx
index d3f8ba628..d5d8687b7 100644
--- a/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx
+++ b/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx
@@ -21,10 +21,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;
@@ -37,12 +42,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();
@@ -233,17 +237,15 @@ export const OrgSettingsPage = () => {
return (
-
-
-
{t('settings-org:title')}
-
- {t('settings-org:description')}
-
-
+
+
{t('settings-org:title')}
+
+ {t('settings-org:description')}
+
-
+
{t('section-members:org-members')}
@@ -262,6 +264,12 @@ export const OrgSettingsPage = () => {
setCompleteInviteLink={setcompleteInviteLink}
/>
+
+
+ Service Accounts
+
+
+
diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx
index e118e455b..58de0e288 100644
--- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx
+++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx
@@ -27,7 +27,8 @@ import {
Td,
Th,
THead,
- Tr} from '@app/components/v2';
+ Tr
+} from '@app/components/v2';
import { usePopUp } from '@app/hooks';
import { useFetchServerStatus } from '@app/hooks/api/serverDetails';
import { IncidentContact } from '@app/hooks/api/types';
diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/OrgMembersTable.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/OrgMembersTable.tsx
index 04ee9da3d..5c2a1fc44 100644
--- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/OrgMembersTable.tsx
+++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/OrgMembersTable.tsx
@@ -103,7 +103,7 @@ export const OrgMembersTable = ({
() => members.find(({ user }) => userId === user?._id)?.role === 'owner',
[userId, members]
);
-
+
const filterdUser = useMemo(
() =>
members.filter(
@@ -132,25 +132,18 @@ export const OrgMembersTable = ({
placeholder="Search members..."
/>
-
- }
- onClick={() => {
- // if (serverDetails?.emailConfigured){
- if (isMoreUserNotAllowed) {
- handlePopUpOpen('upgradePlan');
- } else {
- reset();
- handlePopUpOpen('addMember');
- }
- // } else {
- // handlePopUpOpen('setUpEmail');
- // }
- }}
- >
- Add Member
-
-
+
}
+ onClick={() => {
+ if (isMoreUserNotAllowed) {
+ handlePopUpOpen('upgradePlan');
+ } else {
+ handlePopUpOpen('addMember');
+ }
+ }}
+ >
+ Add Member
+
diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx
new file mode 100644
index 000000000..d788a6783
--- /dev/null
+++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx
@@ -0,0 +1,371 @@
+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;
+
+export const OrgServiceAccountsTable = () => {
+ const router = useRouter();
+ const { currentOrg } = useOrganization();
+ const { currentWorkspace } = useWorkspace();
+
+ const orgId = currentOrg?._id || '';
+ const [step, setStep] = useState(0);
+ const [isAccessKeyCopied, setIsAccessKeyCopied] = useToggle(false);
+ const [isPublicKeyCopied, setIsPublicKeyCopied] = useToggle(false);
+ const [isPrivateKeyCopied, setIsPrivateKeyCopied] = useToggle(false);
+ const [accessKey, setAccessKey] = useState('');
+ const [publicKey, setPublicKey] = 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 (isPublicKeyCopied) {
+ timer = setTimeout(() => setIsPublicKeyCopied.off(), 2000);
+ }
+
+ if (isPrivateKeyCopied) {
+ timer = setTimeout(() => setIsPrivateKeyCopied.off(), 2000);
+ }
+
+ return () => clearTimeout(timer);
+ }, [isAccessKeyCopied, isPublicKeyCopied, isPrivateKeyCopied]);
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ formState: { isSubmitting }
+ } = useForm({ resolver: yupResolver(addServiceAccountFormSchema) });
+
+ const onAddServiceAccount = async ({ name, expiresIn }: TAddServiceAccountForm) => {
+ if (!currentOrg?._id) return;
+
+ const keyPair = generateKeyPair();
+ setPublicKey(keyPair.publicKey);
+ setPrivateKey(keyPair.privateKey);
+
+ const serviceAccountDetails = await createServiceAccount.mutateAsync({
+ name,
+ organizationId: currentOrg?._id,
+ publicKey: keyPair.publicKey,
+ expiresIn: Number(expiresIn)
+ });
+
+ setAccessKey(serviceAccountDetails.serviceAccountAccessKey);
+
+ setStep(1);
+ reset();
+ }
+
+ const onRemoveServiceAccount = async () => {
+ const serviceAccountId = (popUp?.removeServiceAccount?.data as { _id: string })?._id;
+ 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 (
+
+ );
+ case 1:
+ return (
+ <>
+ Access Key
+
+
{accessKey}
+
{
+ navigator.clipboard.writeText(accessKey);
+ setIsAccessKeyCopied.on();
+ }}
+ >
+
+
+ Copy
+
+
+
+ Public Key
+
+
{publicKey}
+
{
+ navigator.clipboard.writeText(publicKey);
+ setIsPublicKeyCopied.on();
+ }}
+ >
+
+
+ Copy
+
+
+
+ Private Key
+
+
{privateKey}
+
{
+ navigator.clipboard.writeText(privateKey);
+ setIsPrivateKeyCopied.on();
+ }}
+ >
+
+
+ Copy
+
+
+
+
+ >
+ );
+ default:
+ return
+ }
+ }
+
+ return (
+
+
+
+ setSearchServiceAccountFilter(e.target.value)}
+ leftIcon={ }
+ placeholder="Search service accounts..."
+ />
+
+
}
+ onClick={() => {
+ setStep(0);
+ reset();
+ handlePopUpOpen('addServiceAccount');
+ }}
+ >
+ Add Service Account
+
+
+
+
+
+ Name
+ Valid Until
+
+
+
+ {isServiceAccountsLoading && }
+ {!isServiceAccountsLoading && (
+ filteredServiceAccounts.map(({
+ name,
+ expiresAt,
+ _id: serviceAccountId
+ }) => {
+ return (
+
+ {name}
+ {new Date(expiresAt).toUTCString()}
+
+
+ {
+ if (currentWorkspace?._id) {
+ router.push(`/settings/org/${currentWorkspace._id}/service-accounts/${serviceAccountId}`);
+ }
+ }}
+ className="mr-2"
+ >
+
+
+ handlePopUpOpen('removeServiceAccount', { _id: serviceAccountId })}
+ >
+
+
+
+
+
+ );
+ })
+ )}
+ {!isServiceAccountsLoading && filteredServiceAccounts?.length === 0 && (
+
+
+
+
+
+ )}
+
+
+
+
{
+ handlePopUpToggle('addServiceAccount', isOpen);
+ reset();
+ }}
+ >
+
+ {renderStep(step)}
+
+
+
handlePopUpToggle('removeServiceAccount', isOpen)}
+ onDeleteApproved={onRemoveServiceAccount}
+ />
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/index.tsx
new file mode 100644
index 000000000..fb22cd2c4
--- /dev/null
+++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/index.tsx
@@ -0,0 +1 @@
+export { OrgServiceAccountsTable } from './OrgServiceAccountsTable';
\ No newline at end of file
diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/index.tsx
index 15a09b0d2..bfe044235 100644
--- a/frontend/src/views/Settings/OrgSettingsPage/components/index.tsx
+++ b/frontend/src/views/Settings/OrgSettingsPage/components/index.tsx
@@ -1,3 +1,5 @@
export { OrgIncidentContactsTable } from './OrgIncidentContactsTable';
export { OrgMembersTable } from './OrgMembersTable';
export { OrgNameChangeSection } from './OrgNameChangeSection';
+export { OrgServiceAccountsTable } from './OrgServiceAccountsTable';
+
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 429876435..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 Name'),
+ expiresIn: yup.string().optional().label('Service Token Expiration'),
permissions: yup.object().shape({
read: yup.boolean().required(),
write: yup.boolean().required()
@@ -202,7 +203,7 @@ export const ServiceTokenSection = ({
defaultValue={String(apiTokenExpiry?.[0]?.value)}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
@@ -213,7 +214,7 @@ export const ServiceTokenSection = ({
className="w-full"
>
{apiTokenExpiry.map(({ label, value }) => (
-
+
{label}
))}
@@ -269,42 +270,6 @@ export const ServiceTokenSection = ({
);
}}
/>
- {/* {
- return (
- {
- onChange(state);
- }}
- >
- Read (default)
-
- );
- }}
- />
- {
- return (
- {
- onChange(state);
- }}
- >
- Write (optional)
-
- );
- }}
- /> */}
{row.name}
{row.environment}
- {new Date(row.expiresAt).toUTCString()}
+ {row.expiresAt && new Date(row.expiresAt).toUTCString()}
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
index 54f527c47..e8bd3b56b 100644
--- a/frontend/tailwind.config.js
+++ b/frontend/tailwind.config.js
@@ -1471,6 +1471,22 @@ module.exports = {
opacity: 1
// transform: "translateY(100%)",
}
+ },
+ drawerRightIn: {
+ '0%': {
+ transform: 'translateX(100%)'
+ },
+ '100%': {
+ transform: 'translateX(0)'
+ }
+ },
+ drawerRightOut: {
+ '0%': {
+ transform: 'translateX(0)'
+ },
+ '100%': {
+ transform: 'translateX(100%)'
+ }
}
},
animation: {
@@ -1478,6 +1494,9 @@ module.exports = {
// MODAL
fadeIn: 'fadeIn 100ms cubic-bezier(0.16, 1, 0.3, 1)',
popIn: 'popIn 150ms cubic-bezier(0.16, 1, 0.3, 1);',
+ // drawer
+ drawerRightIn: 'drawerRightIn 150ms ease-in-out',
+ drawerRightOut: 'drawerRightOut 150ms ease-in-out',
// Dropdown
slideDownAndFade: 'slideDownAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1)',
slideLeftAndFade: 'slideLeftAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1)',
diff --git a/helm-charts/infisical/Chart.lock b/helm-charts/infisical/Chart.lock
index 5170df21b..952009b7b 100644
--- a/helm-charts/infisical/Chart.lock
+++ b/helm-charts/infisical/Chart.lock
@@ -1,9 +1,12 @@
dependencies:
- name: mongodb
repository: https://charts.bitnami.com/bitnami
- version: 13.9.1
+ version: 13.9.4
- name: mailhog
repository: https://codecentric.github.io/helm-charts
version: 5.2.3
-digest: sha256:1ddb3ffef899859222b72547657f57ea303e768d67886a4a57edcb0f773ea83f
-generated: "2023-03-14T12:58:34.387144895+01:00"
+- name: ingress-nginx
+ repository: https://kubernetes.github.io/ingress-nginx
+ version: 4.0.13
+digest: sha256:d1a679e6c30e37da96b7a4b6115e285f61e6ce0dd921ffbe2cf557418c229f33
+generated: "2023-04-08T15:59:12.950942-07:00"
diff --git a/helm-charts/infisical/Chart.yaml b/helm-charts/infisical/Chart.yaml
index 55cc51b9b..aaafc28ba 100644
--- a/helm-charts/infisical/Chart.yaml
+++ b/helm-charts/infisical/Chart.yaml
@@ -7,7 +7,7 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
-version: 0.1.16
+version: 0.1.17
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
@@ -24,3 +24,7 @@ dependencies:
version: "~5.2.3"
repository: "https://codecentric.github.io/helm-charts"
condition: mailhog.enabled
+ - name: ingress-nginx
+ version: 4.0.13
+ repository: https://kubernetes.github.io/ingress-nginx
+ condition: ingress.nginx.enabled
diff --git a/helm-charts/infisical/templates/NOTES.txt b/helm-charts/infisical/templates/NOTES.txt
index 56879e6a2..7d98ee89c 100644
--- a/helm-charts/infisical/templates/NOTES.txt
+++ b/helm-charts/infisical/templates/NOTES.txt
@@ -50,6 +50,7 @@
โ โข infisical-backend : {{ .Values.backend.enabled }}
โ โข mongodb : {{ .Values.mongodb.enabled }}
โ โข mailhog : {{ .Values.mailhog.enabled }}
+| โข nginx : {{ .Values.ingress.nginx.enabled }}
โ
โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
diff --git a/helm-charts/infisical/values.yaml b/helm-charts/infisical/values.yaml
index 67c222278..392c9b3b2 100644
--- a/helm-charts/infisical/values.yaml
+++ b/helm-charts/infisical/values.yaml
@@ -301,6 +301,10 @@ ingress:
## @param ingress.ingressClassName Ingress class name
##
ingressClassName: nginx
+ ## @param ingress.nginx.enabled Ingress controller
+ ##
+ nginx:
+ enabled: false
## @param ingress.annotations Ingress annotations
##
annotations:
diff --git a/nginx/default.conf b/nginx/default.conf
index f52edda3f..4c0e2434d 100644
--- a/nginx/default.conf
+++ b/nginx/default.conf
@@ -11,7 +11,8 @@ server {
proxy_pass http://backend:4000;
proxy_redirect off;
- proxy_cookie_path / "/; secure; HttpOnly; SameSite=strict";
+ # proxy_cookie_path / "/; secure; HttpOnly; SameSite=strict";
+ proxy_cookie_path / "/; HttpOnly; SameSite=strict";
}
location / {