feat(ui): added api hooks and state hooks for dashboard

This commit is contained in:
akhilmhdh
2023-03-19 00:34:14 +05:30
parent 686d3c409d
commit b42c33107e
16 changed files with 588 additions and 26 deletions

View File

@@ -2,6 +2,8 @@ export * from './auth';
export * from './incidentContacts';
export * from './keys';
export * from './organization';
export * from './secrets';
export * from './secretSnapshots';
export * from './serviceTokens';
export * from './subscriptions';
export * from './tags';

View File

@@ -0,0 +1,6 @@
export {
useGetSnapshotSecrets,
useGetWorkspaceSecretSnapshots,
useGetWsSnapshotCount,
usePerformSecretRollback
} from './queries';

View File

@@ -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<string, { id: string; value: string }> = {};
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));
}
});
};

View File

@@ -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<TWorkspaceSecretSnapshot, 'secretVersions'> & {
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;
};

View File

@@ -0,0 +1 @@
export { useBatchSecretsOp, useGetProjectSecrets, useGetSecretVersion } from './queries';

View File

@@ -0,0 +1,173 @@
/* 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) => [{ workspaceId, env }, 'secrets'],
getSecretVersion: (secretId: string) => [{ secretId }, 'secret-versions']
};
const fetchProjectEncryptedSecrets = async (workspaceId: string, env: string) => {
const { data } = await apiRequest.get<{ secrets: EncryptedSecret[] }>('/api/v2/secrets', {
params: {
environment: env,
workspaceId
}
});
return data.secrets;
};
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<string, { id: string; value: string }> = {};
// this used for add-only mode in dashboard
// type won't be there thus only one key is shown
const duplicateSecretKey: Record<string, boolean> = {};
data.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 = 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] = { id: encSecret._id, value: secretValue };
} else {
if (!duplicateSecretKey?.[decryptedSecret.key]) {
sharedSecrets.push(decryptedSecret);
}
duplicateSecretKey[decryptedSecret.key] = 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));
}
});
};

View File

@@ -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<UpdateSecretArg, '_id'>;
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;
decryptFileKey: UserWsKeyPair;
isPaused?: boolean;
onSuccess?: (data: DecryptedSecret[]) => void;
};
export type GetSecretVersionsDTO = {
secretId: string;
limit: number;
offset: number;
decryptFileKey: UserWsKeyPair;
};

View File

@@ -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,

View File

@@ -5,6 +5,8 @@ export {
useDeleteOrgMembership,
useGetOrgUsers,
useGetUser,
useGetUserAction,
useLogoutUser,
useRegisterUserAction,
useUpdateOrgUserRole
} from './queries';

View File

@@ -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'),

View File

@@ -5,7 +5,9 @@ export {
useDeleteWsEnvironment,
useGetUserWorkspaceMemberships,
useGetUserWorkspaces,
useGetUserWsEnvironments,
useGetWorkspaceById,
useRenameWorkspace,
useToggleAutoCapitalization,
useUpdateWsEnvironment} from './queries';
useUpdateWsEnvironment
} from './queries';

View File

@@ -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({

View File

@@ -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 };

View File

@@ -1,3 +1,4 @@
export { useLeaveConfirm } from './useLeaveConfirm';
export { usePersistentState } from './usePersistentState';
export { usePopUp } from './usePopUp';
export { useToggle } from './useToggle';

View File

@@ -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<SetStateAction<boolean>>,
hasUnsavedChanges: boolean;
setHasUnsavedChanges: Dispatch<SetStateAction<boolean>>;
}
export function useLeaveConfirm({
initialValue,
message = leaveConfirmDefaultMessage,
initialValue,
message = leaveConfirmDefaultMessage
}: LeaveConfirmProps): LeaveConfirmReturn {
const router = useRouter()
const router = useRouter();
const [hasUnsavedChanges, setHasUnsavedChanges] = useState<boolean>(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
};
}

View File

@@ -0,0 +1,25 @@
import { useEffect, useState } from 'react';
type TPersisntentStateReturn<T extends unknown> = [T, (val: T) => void];
export const usePersistentState = <T extends unknown>(
initialValue: T,
persistenceKey: string
): TPersisntentStateReturn<T> => {
const [val, setVal] = useState<T>(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];
};