diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 73d6cc7e7..6e41df946 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -83,6 +83,82 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/user-management/users", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + searchTerm: z.string().default(""), + offset: z.coerce.number().default(0), + limit: z.coerce.number().max(100).default(20) + }), + response: { + 200: z.object({ + users: UsersSchema.pick({ + username: true, + firstName: true, + lastName: true, + email: true, + id: true + }).array() + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + const users = await server.services.superAdmin.getUsers({ + ...req.query + }); + + return { + users + }; + } + }); + + server.route({ + method: "DELETE", + url: "/user-management/users/:userId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + userId: z.string() + }), + response: { + 200: z.object({ + users: UsersSchema.pick({ + username: true, + firstName: true, + lastName: true, + email: true, + id: true + }) + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + const users = await server.services.superAdmin.deleteUser(req.params.userId); + + return { + users + }; + } + }); + server.route({ method: "POST", url: "/signup", diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index 21dd32021..01c7eda6d 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -297,7 +297,7 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const user = await server.services.user.deleteMe(req.permission.id); + const user = await server.services.user.deleteUser(req.permission.id); return { user }; } }); diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 70330c4ef..650910681 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -12,7 +12,7 @@ import { AuthMethod } from "../auth/auth-type"; import { TOrgServiceFactory } from "../org/org-service"; import { TUserDALFactory } from "../user/user-dal"; import { TSuperAdminDALFactory } from "./super-admin-dal"; -import { LoginMethod, TAdminSignUpDTO } from "./super-admin-types"; +import { LoginMethod, TAdminGetUsersDTO, TAdminSignUpDTO } from "./super-admin-types"; type TSuperAdminServiceFactoryDep = { serverCfgDAL: TSuperAdminDALFactory; @@ -209,9 +209,25 @@ export const superAdminServiceFactory = ({ return { token, user: userInfo, organization }; }; + const getUsers = ({ offset, limit, searchTerm }: TAdminGetUsersDTO) => { + return userDAL.getUsersByFilter({ + limit, + offset, + searchTerm, + sortBy: "username" + }); + }; + + const deleteUser = async (userId: string) => { + const user = await userDAL.deleteById(userId); + return user; + }; + return { initServerCfg, updateServerCfg, - adminSignUp + adminSignUp, + getUsers, + deleteUser }; }; diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index f622c8f17..2d10941b4 100644 --- a/backend/src/services/super-admin/super-admin-types.ts +++ b/backend/src/services/super-admin/super-admin-types.ts @@ -16,6 +16,12 @@ export type TAdminSignUpDTO = { userAgent: string; }; +export type TAdminGetUsersDTO = { + offset: number; + limit: number; + searchTerm: string; +}; + export enum LoginMethod { EMAIL = "email", GOOGLE = "google", diff --git a/backend/src/services/user/user-dal.ts b/backend/src/services/user/user-dal.ts index e50d3cc68..9ec495ca3 100644 --- a/backend/src/services/user/user-dal.ts +++ b/backend/src/services/user/user-dal.ts @@ -7,10 +7,11 @@ import { TUserActionsUpdate, TUserEncryptionKeys, TUserEncryptionKeysInsert, - TUserEncryptionKeysUpdate + TUserEncryptionKeysUpdate, + TUsers } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify } from "@app/lib/knex"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; export type TUserDALFactory = ReturnType; @@ -18,6 +19,39 @@ export const userDALFactory = (db: TDbClient) => { const userOrm = ormify(db, TableName.Users); const findUserByUsername = async (username: string, tx?: Knex) => userOrm.findOne({ username }, tx); + const getUsersByFilter = async ({ + limit, + offset, + searchTerm, + sortBy + }: { + limit: number; + offset: number; + searchTerm: string; + sortBy?: keyof TUsers; + }) => { + try { + let query = db.replicaNode()(TableName.Users).where("isGhost", "=", false); + if (searchTerm) { + query = query.where((qb) => { + void qb + .whereILike("email", `%${searchTerm}%`) + .orWhereILike("firstName", `%${searchTerm}%`) + .orWhereILike("lastName", `%${searchTerm}%`) + .orWhereLike("username", `%${searchTerm}%`); + }); + } + + if (sortBy) { + query = query.orderBy(sortBy); + } + + return await query.limit(limit).offset(offset).select(selectAllTableCols(TableName.Users)); + } catch (error) { + throw new DatabaseError({ error, name: "Get users by filter" }); + } + }; + // USER ENCRYPTION FUNCTIONS // ------------------------- const findUserEncKeyByUsername = async ({ username }: { username: string }) => { @@ -159,6 +193,7 @@ export const userDALFactory = (db: TDbClient) => { upsertUserEncryptionKey, createUserEncryption, findOneUserAction, - createUserAction + createUserAction, + getUsersByFilter }; }; diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index 5f0106604..f0b043279 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -201,7 +201,7 @@ export const userServiceFactory = ({ return user; }; - const deleteMe = async (userId: string) => { + const deleteUser = async (userId: string) => { const user = await userDAL.deleteById(userId); return user; }; @@ -301,7 +301,7 @@ export const userServiceFactory = ({ toggleUserMfa, updateUserName, updateAuthMethods, - deleteMe, + deleteUser, getMe, createUserAction, getUserAction, diff --git a/docs/contributing/platform/backend/how-to-create-a-feature.mdx b/docs/contributing/platform/backend/how-to-create-a-feature.mdx index e77313dab..8449eb501 100644 --- a/docs/contributing/platform/backend/how-to-create-a-feature.mdx +++ b/docs/contributing/platform/backend/how-to-create-a-feature.mdx @@ -49,8 +49,8 @@ Server-related logic is handled in `/src/server`. To connect the service layer t ## Writing API Routes -1. To create a route component, run `npm generate:component`. +1. To create a route component, run `npm run generate:component`. 2. Select option 3, type the router name in dash-case, and provide the version number. This will generate a router file in `src/server/routes/v/` 1. Implement your logic to connect with the service layer as needed. 2. Import the router component in the version folder's index.ts. For instance, if it's in v1, import it in `v1/index.ts`. - 3. Finally, register it under the appropriate prefix for access. \ No newline at end of file + 3. Finally, register it under the appropriate prefix for access. diff --git a/docs/documentation/platform/secret-sharing.mdx b/docs/documentation/platform/secret-sharing.mdx index 680751820..9c0821508 100644 --- a/docs/documentation/platform/secret-sharing.mdx +++ b/docs/documentation/platform/secret-sharing.mdx @@ -5,7 +5,7 @@ description: "Learn how to share time & view-count bound secrets securely with a --- Developers frequently need to share secrets with team members, contractors, or other third parties, which can be risky due to potential leaks or misuse. -Infisical offers a secure solution for sharing secrets over the internet in a time and view count bound manner. +Infisical offers a secure solution for sharing secrets over the internet in a time and view count bound manner. It is possible to share secrets without signing up via [share.infisical.com](https://share.infisical.com) or via Infisical Dashboard (which has more advanced funcitonality). With its zero-knowledge architecture, secrets shared via Infisical remain unreadable even to Infisical itself. diff --git a/frontend/src/components/v2/Alert/Alert.tsx b/frontend/src/components/v2/Alert/Alert.tsx index c43857da1..3d2dd6393 100644 --- a/frontend/src/components/v2/Alert/Alert.tsx +++ b/frontend/src/components/v2/Alert/Alert.tsx @@ -81,7 +81,7 @@ const AlertDescription = forwardRef< HTMLParagraphElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( -
+
)); AlertDescription.displayName = "AlertDescription"; diff --git a/frontend/src/hooks/api/admin/index.ts b/frontend/src/hooks/api/admin/index.ts index e1c4301d4..fc9fb2c24 100644 --- a/frontend/src/hooks/api/admin/index.ts +++ b/frontend/src/hooks/api/admin/index.ts @@ -1,2 +1,2 @@ -export { useCreateAdminUser, useUpdateServerConfig } from "./mutation"; -export { useGetServerConfig } from "./queries"; +export { useAdminDeleteUser, useCreateAdminUser, useUpdateServerConfig } from "./mutation"; +export { useAdminGetUsers, useGetServerConfig } from "./queries"; diff --git a/frontend/src/hooks/api/admin/mutation.ts b/frontend/src/hooks/api/admin/mutation.ts index 6d25944ef..b927a92b9 100644 --- a/frontend/src/hooks/api/admin/mutation.ts +++ b/frontend/src/hooks/api/admin/mutation.ts @@ -4,7 +4,7 @@ import { apiRequest } from "@app/config/request"; import { organizationKeys } from "../organization/queries"; import { User } from "../users/types"; -import { adminQueryKeys } from "./queries"; +import { adminQueryKeys, adminStandaloneKeys } from "./queries"; import { TCreateAdminUserDTO, TServerConfig } from "./types"; export const useCreateAdminUser = () => { @@ -43,3 +43,19 @@ export const useUpdateServerConfig = () => { } }); }; + +export const useAdminDeleteUser = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (userId: string) => { + await apiRequest.delete(`/api/v1/admin/user-management/users/${userId}`); + + return {}; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [adminStandaloneKeys.getUsers] + }); + } + }); +}; diff --git a/frontend/src/hooks/api/admin/queries.ts b/frontend/src/hooks/api/admin/queries.ts index f64c8bfa9..91368fb9e 100644 --- a/frontend/src/hooks/api/admin/queries.ts +++ b/frontend/src/hooks/api/admin/queries.ts @@ -1,11 +1,17 @@ -import { useQuery, UseQueryOptions } from "@tanstack/react-query"; +import { useInfiniteQuery, useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { TServerConfig } from "./types"; +import { User } from "../types"; +import { AdminGetUsersFilters, TServerConfig } from "./types"; + +export const adminStandaloneKeys = { + getUsers: "get-users" +}; export const adminQueryKeys = { - serverConfig: () => ["server-config"] as const + serverConfig: () => ["server-config"] as const, + getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const }; const fetchServerConfig = async () => { @@ -32,3 +38,24 @@ export const useGetServerConfig = ({ ...options, enabled: options?.enabled ?? true }); + +export const useAdminGetUsers = (filters: AdminGetUsersFilters) => { + return useInfiniteQuery({ + queryKey: adminQueryKeys.getUsers(filters), + queryFn: async ({ pageParam }) => { + const { data } = await apiRequest.get<{ users: User[] }>( + "/api/v1/admin/user-management/users", + { + params: { + ...filters, + offset: pageParam + } + } + ); + + return data.users; + }, + getNextPageParam: (lastPage, pages) => + lastPage.length !== 0 ? pages.length * filters.limit : undefined + }); +}; diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index 4d5add1e0..bfa2e3e36 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -37,3 +37,8 @@ export type TCreateAdminUserDTO = { verifier: string; salt: string; }; + +export type AdminGetUsersFilters = { + limit: number; + searchTerm: string; +}; diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index a8ad89f4c..521c36468 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -9,8 +9,8 @@ export { useAddUserToOrg, useCreateAPIKey, useDeleteAPIKey, + useDeleteMe, useDeleteOrgMembership, - useDeleteUser, useGetMyAPIKeys, useGetMyAPIKeysV2, useGetMyIp, diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index 1a49e8d7c..5ca47fc63 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -28,6 +28,7 @@ export const userKeys = { myAPIKeys: ["api-keys"] as const, myAPIKeysV2: ["api-keys-v2"] as const, mySessions: ["sessions"] as const, + listUsers: ["user-list"] as const, myOrganizationProjects: (orgId: string) => [{ orgId }, "organization-projects"] as const }; @@ -40,7 +41,7 @@ export const fetchUserDetails = async () => { export const useGetUser = () => useQuery(userKeys.getUser, fetchUserDetails); -export const useDeleteUser = () => { +export const useDeleteMe = () => { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/pages/integrations/flyio/create.tsx b/frontend/src/pages/integrations/flyio/create.tsx index baa92524a..eb9766b10 100644 --- a/frontend/src/pages/integrations/flyio/create.tsx +++ b/frontend/src/pages/integrations/flyio/create.tsx @@ -15,6 +15,8 @@ import queryString from "query-string"; import { useCreateIntegration } from "@app/hooks/api"; import { + Alert, + AlertDescription, Button, Card, CardTitle, @@ -40,7 +42,7 @@ export default function FlyioCreateIntegrationPage() { const { data: integrationAuth, isLoading: isIntegrationAuthLoading } = useGetIntegrationAuthById( (integrationAuthId as string) ?? "" ); - const { data: integrationAuthApps, isLoading: isIntegrationAuthAppsLoading } = + const { data: integrationAuthApps = [], isLoading: isIntegrationAuthAppsLoading } = useGetIntegrationAuthApps({ integrationAuthId: (integrationAuthId as string) ?? "" }); @@ -130,6 +132,13 @@ export default function FlyioCreateIntegrationPage() {
+
+ + + All current secrets linked to the related Fly.io project will be deleted before Infisical secrets are pushed to your Fly.io project. + + +
setSearchUserFilter(e.target.value)} + leftIcon={} + placeholder="Search users..." + /> +
+ + + + + + + + + + {isLoading && } + {!isLoading && + data?.pages?.map((users) => + users.map(({ username, email, firstName, lastName, id }) => { + const name = firstName || lastName ? `${firstName} ${lastName}` : "-"; + + return ( + + + + + + ); + }) + )} + +
NameUsername +
{name}{email} + {userId !== id && ( +
+ handlePopUpOpen("removeUser", { username, id })} + > + + +
+ )} +
+ {!isLoading && isEmpty && } +
+ {!isEmpty && ( + + )} +
+ + ); +}; + +export const UserPanel = () => { + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + "removeUser" + ] as const); + + const { mutateAsync: deleteUser } = useAdminDeleteUser(); + + const handleRemoveUser = async () => { + const { id } = popUp?.removeUser?.data as { id: string; username: string }; + + try { + await deleteUser(id); + createNotification({ + type: "success", + text: "Successfully deleted user" + }); + } catch (err) { + createNotification({ + type: "error", + text: "Error deleting user" + }); + } + + handlePopUpClose("removeUser"); + }; + + return ( +
+
+

Users

+
+ + handlePopUpToggle("removeUser", isOpen)} + onDeleteApproved={handleRemoveUser} + /> +
+ ); +}; diff --git a/helm-charts/secrets-operator/Chart.yaml b/helm-charts/secrets-operator/Chart.yaml index 57d07cf74..0dc6a4eae 100644 --- a/helm-charts/secrets-operator/Chart.yaml +++ b/helm-charts/secrets-operator/Chart.yaml @@ -13,9 +13,9 @@ 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: v0.6.2 +version: v0.6.3 # 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 # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "v0.6.2" +appVersion: "v0.6.3" diff --git a/helm-charts/secrets-operator/templates/deployment.yaml b/helm-charts/secrets-operator/templates/deployment.yaml index 2d27b4b5a..ec02df12d 100644 --- a/helm-charts/secrets-operator/templates/deployment.yaml +++ b/helm-charts/secrets-operator/templates/deployment.yaml @@ -82,4 +82,6 @@ spec: securityContext: runAsNonRoot: true serviceAccountName: {{ include "secrets-operator.fullname" . }}-controller-manager - terminationGracePeriodSeconds: 10 \ No newline at end of file + terminationGracePeriodSeconds: 10 + nodeSelector: {{ toYaml .Values.controllerManager.nodeSelector | nindent 8 }} + tolerations: {{ toYaml .Values.controllerManager.tolerations | nindent 8 }} \ No newline at end of file diff --git a/helm-charts/secrets-operator/values.yaml b/helm-charts/secrets-operator/values.yaml index 24310a4a7..b4fb54dba 100644 --- a/helm-charts/secrets-operator/values.yaml +++ b/helm-charts/secrets-operator/values.yaml @@ -43,6 +43,8 @@ controllerManager: replicas: 1 serviceAccount: annotations: {} + nodeSelector: {} + tolerations: [] kubernetesClusterDomain: cluster.local metricsService: ports: