From b32b19bcc11ede08e2325ab71de67b98bd615b11 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 23 Oct 2023 11:58:16 +0100 Subject: [PATCH] Finish API Key V2 --- backend/src/controllers/v3/index.ts | 2 + backend/src/controllers/v3/usersController.ts | 18 ++ .../ee/controllers/v3/apiKeyDataController.ts | 101 +++++++++ backend/src/ee/controllers/v3/index.ts | 4 +- .../v3/serviceTokenDataController.ts | 6 +- backend/src/ee/routes/v3/apiKeyData.ts | 31 +++ backend/src/ee/routes/v3/index.ts | 4 +- backend/src/helpers/auth.ts | 53 +++-- backend/src/index.ts | 6 +- backend/src/models/apiKeyDataV2.ts | 38 ++++ backend/src/models/index.ts | 7 +- backend/src/models/serviceTokenDataV3.ts | 1 + backend/src/routes/v2/serviceTokenData.ts | 8 +- backend/src/routes/v2/users.ts | 2 +- backend/src/routes/v3/index.ts | 2 + backend/src/routes/v3/users.ts | 15 ++ backend/src/validation/apiKeyDataV3.ts | 22 ++ backend/src/validation/index.ts | 1 + backend/src/variables/authentication.ts | 3 +- frontend/src/hooks/api/apiKeys/index.ts | 4 + frontend/src/hooks/api/apiKeys/queries.tsx | 62 ++++++ frontend/src/hooks/api/apiKeys/types.ts | 27 +++ frontend/src/hooks/api/index.tsx | 1 + frontend/src/hooks/api/serviceTokens/index.ts | 3 +- frontend/src/hooks/api/users/index.tsx | 1 + frontend/src/hooks/api/users/queries.tsx | 21 +- .../APIKeyV2Section/APIKeyV2Modal.tsx | 201 ++++++++++++++++++ .../APIKeyV2Section/APIKeyV2Section.tsx | 81 +++++++ .../APIKeyV2Section/APIKeyV2Table.tsx | 107 ++++++++++ .../APIKeyV2Section/index.tsx | 1 + .../PersonalAPIKeyTab/PersonalAPIKeyTab.tsx | 6 +- 31 files changed, 802 insertions(+), 37 deletions(-) create mode 100644 backend/src/controllers/v3/usersController.ts create mode 100644 backend/src/ee/controllers/v3/apiKeyDataController.ts create mode 100644 backend/src/ee/routes/v3/apiKeyData.ts create mode 100644 backend/src/models/apiKeyDataV2.ts create mode 100644 backend/src/routes/v3/users.ts create mode 100644 backend/src/validation/apiKeyDataV3.ts create mode 100644 frontend/src/hooks/api/apiKeys/index.ts create mode 100644 frontend/src/hooks/api/apiKeys/queries.tsx create mode 100644 frontend/src/hooks/api/apiKeys/types.ts create mode 100644 frontend/src/views/Settings/PersonalSettingsPage/APIKeyV2Section/APIKeyV2Modal.tsx create mode 100644 frontend/src/views/Settings/PersonalSettingsPage/APIKeyV2Section/APIKeyV2Section.tsx create mode 100644 frontend/src/views/Settings/PersonalSettingsPage/APIKeyV2Section/APIKeyV2Table.tsx create mode 100644 frontend/src/views/Settings/PersonalSettingsPage/APIKeyV2Section/index.tsx diff --git a/backend/src/controllers/v3/index.ts b/backend/src/controllers/v3/index.ts index e52b1a0ea..b52e0aa41 100644 --- a/backend/src/controllers/v3/index.ts +++ b/backend/src/controllers/v3/index.ts @@ -1,9 +1,11 @@ +import * as usersController from "./usersController"; import * as secretsController from "./secretsController"; import * as workspacesController from "./workspacesController"; import * as authController from "./authController"; import * as signupController from "./signupController"; export { + usersController, authController, secretsController, signupController, diff --git a/backend/src/controllers/v3/usersController.ts b/backend/src/controllers/v3/usersController.ts new file mode 100644 index 000000000..e94173540 --- /dev/null +++ b/backend/src/controllers/v3/usersController.ts @@ -0,0 +1,18 @@ +import { Request, Response } from "express"; +import { APIKeyDataV2 } from "../../models"; + +/** + * Return API keys belonging to current user. + * @param req + * @param res + * @returns + */ +export const getMyAPIKeys = async (req: Request, res: Response) => { + const apiKeyData = await APIKeyDataV2.find({ + user: req.user._id + }); + + return res.status(200).send({ + apiKeyData + }); +} \ No newline at end of file diff --git a/backend/src/ee/controllers/v3/apiKeyDataController.ts b/backend/src/ee/controllers/v3/apiKeyDataController.ts new file mode 100644 index 000000000..d72837844 --- /dev/null +++ b/backend/src/ee/controllers/v3/apiKeyDataController.ts @@ -0,0 +1,101 @@ +import { Request, Response } from "express"; +import { Types } from "mongoose"; +import { APIKeyDataV2 } from "../../../models/apiKeyDataV2"; +import { validateRequest } from "../../../helpers/validation"; +import { BadRequestError } from "../../../utils/errors"; +import * as reqValidator from "../../../validation"; +import { createToken } from "../../../helpers"; +import { AuthTokenType } from "../../../variables"; +import { getAuthSecret } from "../../../config"; + +/** + * Create API key data v2 + * @param req + * @param res + */ +export const createAPIKeyData = async (req: Request, res: Response) => { + const { + body: { + name + } + } = await validateRequest(reqValidator.CreateAPIKeyV3, req); + + const apiKeyData = await new APIKeyDataV2({ + name, + user: req.user._id, + usageCount: 0, + }).save(); + + const apiKey = createToken({ + payload: { + authTokenType: AuthTokenType.API_KEY, + apiKeyDataId: apiKeyData._id.toString(), + userId: req.user._id.toString() + }, + secret: await getAuthSecret() + }); + + return res.status(200).send({ + apiKeyData, + apiKey + }); +} + +/** + * Update API key data v2 with id [apiKeyDataId] + * @param req + * @param res + */ + export const updateAPIKeyData = async (req: Request, res: Response) => { + const { + params: { apiKeyDataId }, + body: { + name, + } + } = await validateRequest(reqValidator.UpdateAPIKeyV3, req); + + const apiKeyData = await APIKeyDataV2.findOneAndUpdate( + { + _id: new Types.ObjectId(apiKeyDataId), + user: req.user._id + }, + { + name + }, + { + new: true + } + ); + + if (!apiKeyData) throw BadRequestError({ + message: "Failed to update API key" + }); + + return res.status(200).send({ + apiKeyData + }); +} + +/** + * Update API key data v2 with id [apiKeyDataId] + * @param req + * @param res + */ + export const deleteAPIKeyData = async (req: Request, res: Response) => { + const { + params: { apiKeyDataId } + } = await validateRequest(reqValidator.DeleteAPIKeyV3, req); + + const apiKeyData = await APIKeyDataV2.findOneAndDelete({ + _id: new Types.ObjectId(apiKeyDataId), + user: req.user._id + }); + + if (!apiKeyData) throw BadRequestError({ + message: "Failed to delete API key" + }); + + return res.status(200).send({ + apiKeyData + }); +} \ No newline at end of file diff --git a/backend/src/ee/controllers/v3/index.ts b/backend/src/ee/controllers/v3/index.ts index af6f3d306..454e0c446 100644 --- a/backend/src/ee/controllers/v3/index.ts +++ b/backend/src/ee/controllers/v3/index.ts @@ -1,5 +1,7 @@ import * as serviceTokenDataController from "./serviceTokenDataController"; +import * as apiKeyDataController from "./apiKeyDataController"; export { - serviceTokenDataController + serviceTokenDataController, + apiKeyDataController } \ No newline at end of file diff --git a/backend/src/ee/controllers/v3/serviceTokenDataController.ts b/backend/src/ee/controllers/v3/serviceTokenDataController.ts index 6b6507cd0..d233cb0d9 100644 --- a/backend/src/ee/controllers/v3/serviceTokenDataController.ts +++ b/backend/src/ee/controllers/v3/serviceTokenDataController.ts @@ -30,7 +30,7 @@ import { EEAuditLogService, EELicenseService } from "../../services"; import { getJwtServiceTokenSecret } from "../../../config"; /** - * Return project key for service token + * Return project key for service token V3 * @param req * @param res */ @@ -57,7 +57,7 @@ export const getServiceTokenDataKey = async (req: Request, res: Response) => { } /** - * Create service token data + * Create service token data V3 * @param req * @param res * @returns @@ -165,7 +165,7 @@ export const createServiceTokenData = async (req: Request, res: Response) => { } /** - * Update service token data with id [serviceTokenDataId] + * Update service token V3 data with id [serviceTokenDataId] * @param req * @param res * @returns diff --git a/backend/src/ee/routes/v3/apiKeyData.ts b/backend/src/ee/routes/v3/apiKeyData.ts new file mode 100644 index 000000000..6d069a719 --- /dev/null +++ b/backend/src/ee/routes/v3/apiKeyData.ts @@ -0,0 +1,31 @@ +import express from "express"; +const router = express.Router(); +import { requireAuth } from "../../../middleware"; +import { AuthMode } from "../../../variables"; +import { apiKeyDataController } from "../../controllers/v3"; + +router.post( + "/", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + apiKeyDataController.createAPIKeyData +); + +router.patch( + "/:apiKeyDataId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + apiKeyDataController.updateAPIKeyData +); + +router.delete( + "/:apiKeyDataId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + apiKeyDataController.deleteAPIKeyData +); + +export default router; \ No newline at end of file diff --git a/backend/src/ee/routes/v3/index.ts b/backend/src/ee/routes/v3/index.ts index 7f75a2755..dd8c13427 100644 --- a/backend/src/ee/routes/v3/index.ts +++ b/backend/src/ee/routes/v3/index.ts @@ -1,5 +1,7 @@ import serviceTokenData from "./serviceTokenData"; +import apiKeyData from "./apiKeyData"; export { - serviceTokenData + serviceTokenData, + apiKeyData } \ No newline at end of file diff --git a/backend/src/helpers/auth.ts b/backend/src/helpers/auth.ts index 2a8a14d8f..a8b13fd82 100644 --- a/backend/src/helpers/auth.ts +++ b/backend/src/helpers/auth.ts @@ -4,6 +4,7 @@ import jwt from "jsonwebtoken"; import bcrypt from "bcrypt"; import { APIKeyData, + APIKeyDataV2, ITokenVersion, IUser, ServiceTokenData, @@ -105,6 +106,7 @@ export const validateAuthMode = ({ /** * Return user payload corresponding to JWT token [authTokenValue] + * that is either for browser / CLI or API Key * @param {Object} obj * @param {String} obj.authTokenValue - JWT token value * @returns {User} user - user corresponding to JWT token @@ -120,7 +122,41 @@ export const getAuthUserPayload = async ({ jwt.verify(authTokenValue, await getAuthSecret()) ); - if (decodedToken.authTokenType !== AuthTokenType.ACCESS_TOKEN) throw UnauthorizedRequestError(); + if ( + decodedToken.authTokenType !== AuthTokenType.ACCESS_TOKEN && + decodedToken.authTokenType !== AuthTokenType.API_KEY + ) { + throw UnauthorizedRequestError(); + } + + if (decodedToken.authTokenType === AuthTokenType.ACCESS_TOKEN) { + const tokenVersion = await TokenVersion.findOneAndUpdate({ + _id: new Types.ObjectId(decodedToken.tokenVersionId), + user: decodedToken.userId + }, { + lastUsed: new Date(), + }); + + if (!tokenVersion) throw UnauthorizedRequestError(); + + if (decodedToken.accessVersion !== tokenVersion.accessVersion) throw UnauthorizedRequestError(); + } else if (decodedToken.authTokenType === AuthTokenType.API_KEY) { + const apiKeyData = await APIKeyDataV2.findOneAndUpdate( + { + _id: new Types.ObjectId(decodedToken.apiKeyDataId), + user: new Types.ObjectId(decodedToken.userId) + }, + { + lastUsed: new Date(), + $inc: { usageCount: 1 } + }, + { + new: true + } + ); + + if (!apiKeyData) throw UnauthorizedRequestError(); + } const user = await User.findOne({ _id: new Types.ObjectId(decodedToken.userId), @@ -130,21 +166,6 @@ export const getAuthUserPayload = async ({ if (!user?.publicKey) throw UnauthorizedRequestError({ message: "Failed to authenticate user with partially set up account" }); - const tokenVersion = await TokenVersion.findOneAndUpdate({ - _id: new Types.ObjectId(decodedToken.tokenVersionId), - user: user._id, - }, { - lastUsed: new Date(), - }); - - if (!tokenVersion) throw UnauthorizedRequestError({ - message: "Failed to validate access token", - }); - - if (decodedToken.accessVersion !== tokenVersion.accessVersion) throw UnauthorizedRequestError({ - message: "Failed to validate access token", - }); - return { actor: { type: ActorType.USER, diff --git a/backend/src/index.ts b/backend/src/index.ts index 08572d981..aa4ac2cd5 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -29,6 +29,7 @@ import { secretApprovalRequest as v1SecretApprovalRequest, secretScanning as v1SecretScanningRouter } from "./ee/routes/v1"; +import { apiKeyData as v3apiKeyDataRouter } from "./ee/routes/v3"; import { serviceTokenData as v3ServiceTokenDataRouter } from "./ee/routes/v3"; import { auth as v1AuthRouter, @@ -68,6 +69,7 @@ import { auth as v3AuthRouter, secrets as v3SecretsRouter, signup as v3SignupRouter, + users as v3UsersRouter, workspaces as v3WorkspacesRouter } from "./routes/v3"; import { healthCheck } from "./routes/status"; @@ -180,7 +182,8 @@ const main = async () => { app.use("/api/v1/organizations", eeOrganizationsRouter); app.use("/api/v1/sso", eeSSORouter); app.use("/api/v1/cloud-products", eeCloudProductsRouter); - app.use("/api/v3/service-token", v3ServiceTokenDataRouter); + app.use("/api/v3/api-key", v3apiKeyDataRouter); // new + app.use("/api/v3/service-token", v3ServiceTokenDataRouter); // new // v1 routes app.use("/api/v1/signup", v1SignupRouter); @@ -226,6 +229,7 @@ const main = async () => { app.use("/api/v3/secrets", v3SecretsRouter); app.use("/api/v3/workspaces", v3WorkspacesRouter); app.use("/api/v3/signup", v3SignupRouter); + app.use("/api/v3/users", v3UsersRouter); // api docs app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerFile)); diff --git a/backend/src/models/apiKeyDataV2.ts b/backend/src/models/apiKeyDataV2.ts new file mode 100644 index 000000000..6775a0878 --- /dev/null +++ b/backend/src/models/apiKeyDataV2.ts @@ -0,0 +1,38 @@ +import { Document, Schema, Types, model } from "mongoose"; + +export interface IAPIKeyDataV2 extends Document { + _id: Types.ObjectId; + name: string; + user: Types.ObjectId; + lastUsed?: Date + usageCount: number; + expiresAt?: Date; +} + +const apiKeyDataV2Schema = new Schema( + { + name: { + type: String, + required: true + }, + user: { + type: Schema.Types.ObjectId, + ref: "User", + required: true + }, + lastUsed: { + type: Date, + required: false + }, + usageCount: { + type: Number, + default: 0, + required: true + } + }, + { + timestamps: true + } +); + +export const APIKeyDataV2 = model("APIKeyDataV2", apiKeyDataV2Schema); \ No newline at end of file diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 99fc9c4f1..6bd1ebef6 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -24,9 +24,10 @@ export * from "./user"; export * from "./userAction"; export * from "./workspace"; export * from "./serviceTokenData"; // TODO: deprecate -export * from "./apiKeyData"; +export * from "./serviceTokenDataV3"; +export * from "./serviceTokenDataV3Key"; +export * from "./apiKeyData"; // TODO: deprecate +export * from "./apiKeyDataV2"; export * from "./loginSRPDetail"; export * from "./tokenVersion"; export * from "./webhooks"; -export * from "./serviceTokenDataV3"; -export * from "./serviceTokenDataV3Key"; diff --git a/backend/src/models/serviceTokenDataV3.ts b/backend/src/models/serviceTokenDataV3.ts index a9758422e..c9895402f 100644 --- a/backend/src/models/serviceTokenDataV3.ts +++ b/backend/src/models/serviceTokenDataV3.ts @@ -54,6 +54,7 @@ const serviceTokenDataV3Schema = new Schema( }, isActive: { type: Boolean, + default: true, required: true }, lastUsed: { diff --git a/backend/src/routes/v2/serviceTokenData.ts b/backend/src/routes/v2/serviceTokenData.ts index 611cf6003..c959cfa56 100644 --- a/backend/src/routes/v2/serviceTokenData.ts +++ b/backend/src/routes/v2/serviceTokenData.ts @@ -6,7 +6,7 @@ import { import { AuthMode } from "../../variables"; import { serviceTokenDataController } from "../../controllers/v2"; -router.get( +router.get( // TODO: deprecate (moving to ST V3) "/", requireAuth({ acceptedAuthModes: [AuthMode.SERVICE_TOKEN] @@ -14,7 +14,7 @@ router.get( serviceTokenDataController.getServiceTokenData ); -router.post( +router.post( // TODO: deprecate (moving to ST V3) "/", requireAuth({ acceptedAuthModes: [AuthMode.JWT] @@ -22,7 +22,7 @@ router.post( serviceTokenDataController.createServiceTokenData ); -router.delete( +router.delete( // TODO: deprecate (moving to ST V3) "/:serviceTokenDataId", requireAuth({ acceptedAuthModes: [AuthMode.JWT] @@ -30,4 +30,4 @@ router.delete( serviceTokenDataController.deleteServiceTokenData ); -export default router; +export default router; \ No newline at end of file diff --git a/backend/src/routes/v2/users.ts b/backend/src/routes/v2/users.ts index cc7a7ec0d..54c16898f 100644 --- a/backend/src/routes/v2/users.ts +++ b/backend/src/routes/v2/users.ts @@ -36,7 +36,7 @@ router.get( usersController.getMyOrganizations ); -router.get( +router.get( // TODO: deprecate (moving to API Key V2) "/me/api-keys", requireAuth({ acceptedAuthModes: [AuthMode.JWT] diff --git a/backend/src/routes/v3/index.ts b/backend/src/routes/v3/index.ts index 1a95439ab..a2b64294c 100644 --- a/backend/src/routes/v3/index.ts +++ b/backend/src/routes/v3/index.ts @@ -1,10 +1,12 @@ import auth from "./auth"; +import users from "./users"; import secrets from "./secrets"; import workspaces from "./workspaces"; import signup from "./signup"; export { auth, + users, secrets, signup, workspaces diff --git a/backend/src/routes/v3/users.ts b/backend/src/routes/v3/users.ts new file mode 100644 index 000000000..f465791f8 --- /dev/null +++ b/backend/src/routes/v3/users.ts @@ -0,0 +1,15 @@ +import express from "express"; +const router = express.Router(); +import { requireAuth } from "../../middleware"; +import { AuthMode } from "../../variables"; +import { usersController } from "../../controllers/v3"; + +router.get( + "/me/api-keys", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + usersController.getMyAPIKeys +); + +export default router; \ No newline at end of file diff --git a/backend/src/validation/apiKeyDataV3.ts b/backend/src/validation/apiKeyDataV3.ts new file mode 100644 index 000000000..c92ce468c --- /dev/null +++ b/backend/src/validation/apiKeyDataV3.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +export const CreateAPIKeyV3 = z.object({ + body: z.object({ + name: z.string().trim() + }) +}); + +export const UpdateAPIKeyV3 = z.object({ + params: z.object({ + apiKeyDataId: z.string().trim() + }), + body: z.object({ + name: z.string().trim() + }) +}); + +export const DeleteAPIKeyV3 = z.object({ + params: z.object({ + apiKeyDataId: z.string().trim() + }) +}); \ No newline at end of file diff --git a/backend/src/validation/index.ts b/backend/src/validation/index.ts index a823f8095..447948c5a 100644 --- a/backend/src/validation/index.ts +++ b/backend/src/validation/index.ts @@ -10,3 +10,4 @@ export * from "./secrets"; export * from "./serviceAccount"; export * from "./serviceTokenData"; export * from "./serviceTokenDataV3"; +export * from "./apiKeyDataV3"; diff --git a/backend/src/variables/authentication.ts b/backend/src/variables/authentication.ts index 3aa06a200..30ba4bf14 100644 --- a/backend/src/variables/authentication.ts +++ b/backend/src/variables/authentication.ts @@ -3,7 +3,8 @@ export enum AuthTokenType { REFRESH_TOKEN = "refreshToken", SIGNUP_TOKEN = "signupToken", MFA_TOKEN = "mfaToken", - PROVIDER_TOKEN = "providerToken" + PROVIDER_TOKEN = "providerToken", + API_KEY = "apiKey" } export enum AuthMode { diff --git a/frontend/src/hooks/api/apiKeys/index.ts b/frontend/src/hooks/api/apiKeys/index.ts new file mode 100644 index 000000000..c63762b97 --- /dev/null +++ b/frontend/src/hooks/api/apiKeys/index.ts @@ -0,0 +1,4 @@ +export { + useCreateAPIKeyV2, + useDeleteAPIKeyV2, + useUpdateAPIKeyV2} from "./queries"; \ No newline at end of file diff --git a/frontend/src/hooks/api/apiKeys/queries.tsx b/frontend/src/hooks/api/apiKeys/queries.tsx new file mode 100644 index 000000000..2d19146fe --- /dev/null +++ b/frontend/src/hooks/api/apiKeys/queries.tsx @@ -0,0 +1,62 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { userKeys } from "../users/queries"; +import { + APIKeyDataV2, + CreateAPIKeyDataV2DTO, + CreateServiceTokenDataV3Res, + DeleteAPIKeyDataV2DTO, + UpdateAPIKeyDataV2DTO} from "./types"; + +export const useCreateAPIKeyV2 = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + name + }) => { + const { data } = await apiRequest.post("/api/v3/api-key", { + name + }); + + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries(userKeys.myAPIKeysV2); + } + }); +}; + +export const useUpdateAPIKeyV2 = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + apiKeyDataId, + name + }) => { + const { data: { apiKeyData } } = await apiRequest.patch(`/api/v3/api-key/${apiKeyDataId}`, { + name + }); + return apiKeyData; + }, + onSuccess: () => { + queryClient.invalidateQueries(userKeys.myAPIKeysV2); + } + }); +}; + +export const useDeleteAPIKeyV2 = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + apiKeyDataId + }) => { + const { data: { apiKeyData } } = await apiRequest.delete(`/api/v3/api-key/${apiKeyDataId}`); + return apiKeyData; + }, + onSuccess: () => { + queryClient.invalidateQueries(userKeys.myAPIKeysV2); + } + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/api/apiKeys/types.ts b/frontend/src/hooks/api/apiKeys/types.ts new file mode 100644 index 000000000..9774c5824 --- /dev/null +++ b/frontend/src/hooks/api/apiKeys/types.ts @@ -0,0 +1,27 @@ +export type APIKeyDataV2 = { + _id: string; + name: string; + user: string; + lastUsed?: string; + usageCount: number; + createdAt: string; + updatedAt: string; + }; + +export type CreateAPIKeyDataV2DTO = { + name: string; +} + +export type CreateServiceTokenDataV3Res = { + apiKeyData: APIKeyDataV2; + apiKey: string; +} + +export type UpdateAPIKeyDataV2DTO = { + apiKeyDataId: string; + name: string; +} + +export type DeleteAPIKeyDataV2DTO = { + apiKeyDataId: string; +} \ No newline at end of file diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index b065b23a1..008f1e2fb 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -1,3 +1,4 @@ +export * from "./apiKeys"; export * from "./auditLogs"; export * from "./auth"; export * from "./bots"; diff --git a/frontend/src/hooks/api/serviceTokens/index.ts b/frontend/src/hooks/api/serviceTokens/index.ts index 2ae762611..01422d833 100644 --- a/frontend/src/hooks/api/serviceTokens/index.ts +++ b/frontend/src/hooks/api/serviceTokens/index.ts @@ -4,4 +4,5 @@ export { useDeleteServiceToken, useDeleteServiceTokenV3, useGetUserWsServiceTokens, - useUpdateServiceTokenV3} from "./queries"; + useUpdateServiceTokenV3 +} from "./queries"; diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index ce688fb0a..c04f7aa9f 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -7,6 +7,7 @@ export { useDeleteOrgMembership, useDeleteUser, useGetMyAPIKeys, + useGetMyAPIKeysV2, useGetMyIp, useGetMyOrganizationProjects, useGetMySessions, diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index adf0ccd09..45d8c323c 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -7,6 +7,7 @@ import { import { apiRequest } from "@app/config/request"; import { setAuthToken } from "@app/reactQuery"; +import { APIKeyDataV2 } from "../apiKeys/types"; import { useUploadWsKey } from "../keys/queries"; import { workspaceKeys } from "../workspace/queries"; import { @@ -24,12 +25,13 @@ import { User } from "./types"; -const userKeys = { +export const userKeys = { getUser: ["user"] as const, userAction: ["user-action"] as const, getOrgUsers: (orgId: string) => [{ orgId }, "user"], myIp: ["ip"] as const, myAPIKeys: ["api-keys"] as const, + myAPIKeysV2: ["api-keys-v2"] as const, mySessions: ["sessions"] as const, myOrganizationProjects: (orgId: string) => [{ orgId }, "organization-projects"] as const }; @@ -270,7 +272,7 @@ export const useGetMyIp = () => { }); }; -export const useGetMyAPIKeys = () => { +export const useGetMyAPIKeys = () => { // TODO: deprecate (moving to API Key V2) return useQuery({ queryKey: userKeys.myAPIKeys, queryFn: async () => { @@ -281,7 +283,18 @@ export const useGetMyAPIKeys = () => { }); }; -export const useCreateAPIKey = () => { +export const useGetMyAPIKeysV2 = () => { + return useQuery({ + queryKey: userKeys.myAPIKeysV2, + queryFn: async () => { + const { data: { apiKeyData } } = await apiRequest.get<{ apiKeyData: APIKeyDataV2[] }>("/api/v3/users/me/api-keys"); + return apiKeyData; + }, + enabled: true + }); +}; + +export const useCreateAPIKey = () => { // TODO: deprecate (moving to API Key V2) const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ name, expiresIn }: { name: string; expiresIn: number }) => { @@ -298,7 +311,7 @@ export const useCreateAPIKey = () => { }); }; -export const useDeleteAPIKey = () => { +export const useDeleteAPIKey = () => { // TODO: deprecate (moving to API Key V2) const queryClient = useQueryClient(); return useMutation({ mutationFn: async (apiKeyDataId: string) => { diff --git a/frontend/src/views/Settings/PersonalSettingsPage/APIKeyV2Section/APIKeyV2Modal.tsx b/frontend/src/views/Settings/PersonalSettingsPage/APIKeyV2Section/APIKeyV2Modal.tsx new file mode 100644 index 000000000..f0da313c1 --- /dev/null +++ b/frontend/src/views/Settings/PersonalSettingsPage/APIKeyV2Section/APIKeyV2Modal.tsx @@ -0,0 +1,201 @@ +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + FormControl, + IconButton, + Input, + Modal, + ModalContent} from "@app/components/v2"; +import { useToggle } from "@app/hooks"; +import { + useCreateAPIKeyV2, + useUpdateAPIKeyV2 +} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = yup.object({ + name: yup.string().required("API Key V2 name is required") +}).required(); + +export type FormData = yup.InferType; + +type Props = { + popUp: UsePopUpState<["apiKeyV2"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["apiKeyV2"]>, state?: boolean) => void; +}; + +// TODO: copy to clipboard stuff + +export const APIKeyV2Modal = ({ + popUp, + handlePopUpToggle +}: Props) => { + const [newAPIKey, setNewAPIKey] = useState(""); + const [isAPIKeyCopied, setIsAPIKeyCopied] = useToggle(false); + + const { createNotification } = useNotificationContext(); + + const { mutateAsync: createMutateAsync } = useCreateAPIKeyV2(); + const { mutateAsync: updateMutateAsync } = useUpdateAPIKeyV2(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + name: "" + } + }); + + useEffect(() => { + let timer: NodeJS.Timeout; + + if (isAPIKeyCopied) { + timer = setTimeout(() => setIsAPIKeyCopied.off(), 2000); + } + + return () => clearTimeout(timer); + }, [setIsAPIKeyCopied]); + + useEffect(() => { + const apiKeyData = popUp?.apiKeyV2?.data as { + apiKeyDataId: string; + name: string; + }; + + if (apiKeyData) { + reset({ + name: apiKeyData.name + }); + } else { + reset({ + name: "" + }); + } + }, [popUp?.apiKeyV2?.data]); + + const copyTokenToClipboard = () => { + navigator.clipboard.writeText(newAPIKey); + setIsAPIKeyCopied.on(); + }; + + const onFormSubmit = async ({ + name + }: FormData) => { + try { + const apiKeyData = popUp?.apiKeyV2?.data as { + apiKeyDataId: string; + name: string; + }; + + if (apiKeyData) { + // update + + await updateMutateAsync({ + apiKeyDataId: apiKeyData.apiKeyDataId, + name + }); + + handlePopUpToggle("apiKeyV2", false); + } else { + // create + + const { apiKey } = await createMutateAsync({ + name + }); + + setNewAPIKey(apiKey); + } + + createNotification({ + text: `Successfully ${popUp?.apiKeyV2?.data ? "updated" : "created"} API Key`, + type: "success" + }); + + reset(); + + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${popUp?.apiKeyV2?.data ? "updated" : "created"} API Key`, + type: "error" + }); + } + } + + const hasAPIKey = Boolean(newAPIKey); + + return ( + { + handlePopUpToggle("apiKeyV2", isOpen); + reset(); + setNewAPIKey(""); + }} + > + + {!hasAPIKey ? ( +
+ ( + + + + )} + /> +
+ + +
+ + ) : ( +
+

{newAPIKey}

+ + + + Click to copy + + +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/PersonalSettingsPage/APIKeyV2Section/APIKeyV2Section.tsx b/frontend/src/views/Settings/PersonalSettingsPage/APIKeyV2Section/APIKeyV2Section.tsx new file mode 100644 index 000000000..213d4bd65 --- /dev/null +++ b/frontend/src/views/Settings/PersonalSettingsPage/APIKeyV2Section/APIKeyV2Section.tsx @@ -0,0 +1,81 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + DeleteActionModal +} from "@app/components/v2"; +import { useDeleteAPIKeyV2 } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { APIKeyV2Modal } from "./APIKeyV2Modal"; +import { APIKeyV2Table } from "./APIKeyV2Table"; + +export const APIKeyV2Section = () => { + const { createNotification } = useNotificationContext(); + const { mutateAsync: deleteMutateAsync } = useDeleteAPIKeyV2(); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "apiKeyV2", + "deleteAPIKeyV2" + ] as const); + + const onDeleteAPIKeyDataSubmit = async (apiKeyDataId: string) => { + try { + await deleteMutateAsync({ + apiKeyDataId + }); + + createNotification({ + text: "Successfully deleted API Key V2", + type: "success" + }); + + handlePopUpClose("deleteAPIKeyV2"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete API Key V2", + type: "error" + }); + } + } + + return ( +
+
+

+ API Keys V2 (Beta) +

+ +
+ + + handlePopUpToggle("deleteAPIKeyV2", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onDeleteAPIKeyDataSubmit( + (popUp?.deleteAPIKeyV2?.data as { apiKeyDataId: string })?.apiKeyDataId + ) + } + /> +
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/PersonalSettingsPage/APIKeyV2Section/APIKeyV2Table.tsx b/frontend/src/views/Settings/PersonalSettingsPage/APIKeyV2Section/APIKeyV2Table.tsx new file mode 100644 index 000000000..132723915 --- /dev/null +++ b/frontend/src/views/Settings/PersonalSettingsPage/APIKeyV2Section/APIKeyV2Table.tsx @@ -0,0 +1,107 @@ +import { faKey, faPencil, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; + +import { + EmptyState, + IconButton, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { + useGetMyAPIKeysV2 +} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteAPIKeyV2", "apiKeyV2"]>, + data?: { + apiKeyDataId?: string; + name?: string; + } + ) => void; + }; + +export const APIKeyV2Table = ({ + handlePopUpOpen +}: Props) => { + const { data, isLoading } = useGetMyAPIKeysV2(); + return ( + + + + + + + + + + + {isLoading && } + {!isLoading && + data && + data.length > 0 && + data.map(({ + _id, + name, + lastUsed, + createdAt + }) => { + return ( + + + + + + + ); + })} + {!isLoading && data && data?.length === 0 && ( + + + + )} + +
NameLast UsedCreated At +
{name}{lastUsed ? format(new Date(lastUsed), "yyyy-MM-dd") : "-"}{format(new Date(createdAt), "yyyy-MM-dd")} + { + handlePopUpOpen("apiKeyV2", { + apiKeyDataId: _id, + name + }); + }} + size="lg" + colorSchema="primary" + variant="plain" + ariaLabel="update" + > + + + { + handlePopUpOpen("deleteAPIKeyV2", { + apiKeyDataId: _id + }); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="ml-4" + > + + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/PersonalSettingsPage/APIKeyV2Section/index.tsx b/frontend/src/views/Settings/PersonalSettingsPage/APIKeyV2Section/index.tsx new file mode 100644 index 000000000..b215d8028 --- /dev/null +++ b/frontend/src/views/Settings/PersonalSettingsPage/APIKeyV2Section/index.tsx @@ -0,0 +1 @@ +export { APIKeyV2Section } from "./APIKeyV2Section"; \ No newline at end of file diff --git a/frontend/src/views/Settings/PersonalSettingsPage/PersonalAPIKeyTab/PersonalAPIKeyTab.tsx b/frontend/src/views/Settings/PersonalSettingsPage/PersonalAPIKeyTab/PersonalAPIKeyTab.tsx index 24d40a8b1..c27e280a5 100644 --- a/frontend/src/views/Settings/PersonalSettingsPage/PersonalAPIKeyTab/PersonalAPIKeyTab.tsx +++ b/frontend/src/views/Settings/PersonalSettingsPage/PersonalAPIKeyTab/PersonalAPIKeyTab.tsx @@ -1,7 +1,11 @@ +// import { APIKeyV2Section } from "../APIKeyV2Section"; import { APIKeySection } from "../APIKeySection"; export const PersonalAPIKeyTab = () => { return ( - + <> + {/* */} + + ); } \ No newline at end of file