diff --git a/src/pages/apps/[id]/environments/[env-id]/config/_components/TabAPIKey.spec.tsx b/src/pages/apps/[id]/environments/[env-id]/config/_components/TabAPIKey.spec.tsx
new file mode 100644
index 00000000..bf7c68ca
--- /dev/null
+++ b/src/pages/apps/[id]/environments/[env-id]/config/_components/TabAPIKey.spec.tsx
@@ -0,0 +1,97 @@
+import type { Scope } from "nock";
+import { RenderResult, waitFor } from "@testing-library/react";
+import { fireEvent, render } from "@testing-library/react";
+import mockApp from "~/testing/data/mock_app";
+import mockEnvironments from "~/testing/data/mock_environments";
+import {
+ mockFetchAPIKeys,
+ mockDeleteAPIKey,
+} from "~/testing/nocks/nock_api_keys";
+import TabAPIKey from "./TabAPIKey";
+
+interface WrapperProps {
+ app?: App;
+ environments?: Environment[];
+ hash?: string;
+ setRefreshToken?: () => void;
+}
+
+describe("~/pages/apps/[id]/environments/[env-id]/config/_components/TabAPIKey.tsx", () => {
+ let wrapper: RenderResult;
+ let fetchScope: Scope;
+ let currentApp: App;
+ let currentEnv: Environment;
+ let currentEnvs: Environment[];
+
+ const createWrapper = ({ app, environments }: WrapperProps) => {
+ currentApp = app || mockApp();
+ currentApp.id = "1644802351"; // Same as api key id
+
+ currentEnvs = environments || mockEnvironments({ app: currentApp });
+ currentEnv = currentEnvs[0];
+
+ fetchScope = mockFetchAPIKeys({
+ appId: currentApp.id,
+ envId: currentEnv.id!,
+ });
+
+ wrapper = render();
+ };
+
+ test("should fetch api keys", async () => {
+ createWrapper({});
+
+ await waitFor(() => {
+ expect(fetchScope.isDone()).toBe(true);
+ });
+
+ const subheader =
+ "This key will allow you to interact with our API and modify this environment.";
+
+ // Header
+ expect(wrapper.getByText("API Key")).toBeTruthy();
+ expect(wrapper.getByText(subheader)).toBeTruthy();
+
+ // API Keys
+ expect(wrapper.getByText("Default")).toBeTruthy();
+ expect(wrapper.getByText("CI")).toBeTruthy();
+ });
+
+ test("should delete api key", async () => {
+ createWrapper({});
+
+ await waitFor(() => {
+ expect(wrapper.getByText("CI")).toBeTruthy();
+ });
+
+ fireEvent.click(wrapper.getAllByLabelText("Remove API Key").at(1)!);
+
+ await waitFor(() => {
+ expect(wrapper.getByText("Confirm action")).toBeTruthy();
+ });
+
+ const scope = mockDeleteAPIKey({
+ appId: currentApp.id,
+ envId: currentEnv.id!,
+ keyId: "9868814106",
+ });
+
+ // Should refetch api keys
+ const fetchScope2 = mockFetchAPIKeys({
+ appId: currentApp.id,
+ envId: currentEnv.id!,
+ response: { keys: [] },
+ });
+
+ fireEvent.click(wrapper.getByText("Yes, continue"));
+
+ await waitFor(() => {
+ expect(scope.isDone()).toBe(true);
+ expect(fetchScope2.isDone()).toBe(true);
+ // Should close modal
+ expect(() => wrapper.getByText("Confirm action")).toThrow();
+ // Should no longer have API keys
+ expect(() => wrapper.getByText("Default")).toThrow();
+ });
+ });
+});
diff --git a/src/pages/apps/[id]/environments/[env-id]/config/_components/TabAPIKey.tsx b/src/pages/apps/[id]/environments/[env-id]/config/_components/TabAPIKey.tsx
index 31ec41e5..89af2d21 100644
--- a/src/pages/apps/[id]/environments/[env-id]/config/_components/TabAPIKey.tsx
+++ b/src/pages/apps/[id]/environments/[env-id]/config/_components/TabAPIKey.tsx
@@ -8,15 +8,17 @@ import Button from "@mui/lab/LoadingButton";
import TextInput from "@mui/material/TextField";
import VisibilityIcon from "@mui/icons-material/Visibility";
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
+import DeleteIcon from "@mui/icons-material/Delete";
import Modal from "~/components/ModalV2";
+import ConfirmModal from "~/components/ConfirmModal";
import Spinner from "~/components/Spinner";
import InputDescription from "~/components/InputDescription";
-import { useFetchAPIKeys, generateNewAPIKey } from "../actions";
+import { useFetchAPIKeys, generateNewAPIKey, deleteAPIKey } from "../actions";
interface Props {
app: App;
environment: Environment;
- setRefreshToken: (v: number) => void;
+ setRefreshToken?: (v: number) => void; // This is there just to accomodate the Tab signature. It's not really used.
}
interface APIKeyModalProps {
@@ -72,13 +74,16 @@ function APIKeyModal({ onSubmit, onClose, loading, error }: APIKeyModalProps) {
export default function TabAPIKey({ app, environment: env }: Props) {
const [isVisible, setIsVisible] = useState("");
+ const [refreshToken, setRefreshToken] = useState();
const [success, setSuccess] = useState(false);
const [modalError, setModalError] = useState("");
const [modalLoading, setModalLoading] = useState(false);
+ const [apiKeyToDelete, setApiKeyToDelete] = useState();
const [isModalOpen, setIsModalOpen] = useState(false);
const { loading, error, keys, setKeys } = useFetchAPIKeys({
appId: app.id,
envId: env.id!,
+ refreshToken,
});
const handleNewKey = (name: string) => {
@@ -146,9 +151,12 @@ export default function TabAPIKey({ app, environment: env }: Props) {
{keys.map(apiKey => (
- {apiKey.name}
-
- {isVisible === apiKey.id ? apiKey.token : "*".repeat(32)}
+
+ {apiKey.name}
+
+ {isVisible === apiKey.id ? apiKey.token : "*".repeat(32)}
+
+
+
+ {
+ setIsVisible(isVisible === apiKey.id ? "" : apiKey.id);
+ }}
+ >
+ {isVisible === apiKey.id ? (
+
+ ) : (
+
+ )}
+
+ {
+ setApiKeyToDelete(apiKey);
+ }}
+ >
+
+
- {
- setIsVisible(isVisible === apiKey.id ? "" : apiKey.id);
- }}
- >
- {isVisible ? : }
-
))}
@@ -201,6 +239,38 @@ export default function TabAPIKey({ app, environment: env }: Props) {
onSubmit={handleNewKey}
/>
)}
+ {apiKeyToDelete && (
+ {
+ setApiKeyToDelete(undefined);
+ }}
+ onConfirm={({ setLoading, setError }) => {
+ setLoading(true);
+ setError(null);
+ setSuccess(false);
+
+ deleteAPIKey(apiKeyToDelete)
+ .then(() => {
+ setRefreshToken(Date.now());
+ })
+ .catch(() => {
+ setError("Something went wrong while deleting the API key.");
+ })
+ .finally(() => {
+ setLoading(false);
+ setApiKeyToDelete(undefined);
+ });
+
+ return "";
+ }}
+ >
+
+ This will delete the API key.
+
If you have any integration that uses this API key, it will
+ stop working.
+
+
+ )}
);
}
diff --git a/src/pages/apps/[id]/environments/[env-id]/config/actions.ts b/src/pages/apps/[id]/environments/[env-id]/config/actions.ts
index 74b6f9bc..cd6310ea 100644
--- a/src/pages/apps/[id]/environments/[env-id]/config/actions.ts
+++ b/src/pages/apps/[id]/environments/[env-id]/config/actions.ts
@@ -324,9 +324,14 @@ export const deleteEnvironment = ({
interface FetchAPIKeyProps {
appId: string;
envId: string;
+ refreshToken?: number;
}
-export const useFetchAPIKeys = ({ appId, envId }: FetchAPIKeyProps) => {
+export const useFetchAPIKeys = ({
+ appId,
+ envId,
+ refreshToken,
+}: FetchAPIKeyProps) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState();
const [keys, setKeys] = useState([]);
@@ -346,11 +351,17 @@ export const useFetchAPIKeys = ({ appId, envId }: FetchAPIKeyProps) => {
.finally(() => {
setLoading(false);
});
- }, [appId, envId]);
+ }, [appId, envId, refreshToken]);
return { loading, error, keys, setKeys };
};
+export const deleteAPIKey = (apiKey: APIKey) => {
+ return api.delete<{ keys: APIKey[] }>(
+ `/app/${apiKey.appId}/env/${apiKey.envId}/api-key?keyId=${apiKey.id}`
+ );
+};
+
interface GenerateNewAPIKeyProps {
appId: string;
envId?: string;
diff --git a/src/types/api_key.d.ts b/src/types/api_key.d.ts
index 84e43164..210f1417 100644
--- a/src/types/api_key.d.ts
+++ b/src/types/api_key.d.ts
@@ -4,4 +4,5 @@ declare interface APIKey {
appId: string;
token: string;
envId?: string;
+ scope: "env";
}
diff --git a/testing/data/index.ts b/testing/data/index.ts
index 100cbf31..4cda9a37 100644
--- a/testing/data/index.ts
+++ b/testing/data/index.ts
@@ -10,3 +10,4 @@ export { default as mockAppResponse } from "./mock_app_response";
export { default as mockUserResponse } from "./mock_user_response";
export { default as mockRemoteConfigResponse } from "./mock_remote_config_response";
export { default as mockAdditionalSettingsResponse } from "./mock_settings_response";
+export { default as mockAPIKeysResponse } from "./mock_api_keys_response";
diff --git a/testing/data/mock_api_keys_response.ts b/testing/data/mock_api_keys_response.ts
new file mode 100644
index 00000000..fe4b629a
--- /dev/null
+++ b/testing/data/mock_api_keys_response.ts
@@ -0,0 +1,26 @@
+interface APIKeyResponse {
+ keys: APIKey[];
+}
+
+export default (): APIKeyResponse => ({
+ keys: [
+ {
+ appId: "1644802351",
+ envId: "1429333243019",
+ id: "8224011755",
+ scope: "env",
+ name: "Default",
+ token:
+ "SK_fkopNcPhEizN5sV7cZx69tQ1VJZSV6ZiYBdpt6qiojkOB6xbUkCfGbBjR9IxqK",
+ },
+ {
+ appId: "1644802351",
+ envId: "1429333243019",
+ id: "9868814106",
+ scope: "env",
+ name: "CI",
+ token:
+ "SK_m9nvOqzHFXiNrGEzvEQfdJK6isRCLnHpUulb5URvaSMme8fDeNUzPdhzVQLczu",
+ },
+ ],
+});
diff --git a/testing/nocks/nock_api_keys.ts b/testing/nocks/nock_api_keys.ts
new file mode 100644
index 00000000..f8eec038
--- /dev/null
+++ b/testing/nocks/nock_api_keys.ts
@@ -0,0 +1,40 @@
+import nock from "nock";
+import * as data from "../data";
+
+const endpoint = process.env.API_DOMAIN || "";
+
+interface MockFetchAPIKeysProps {
+ appId: string;
+ envId: string;
+ status?: number;
+ response?: { keys: APIKey[] };
+}
+
+export const mockFetchAPIKeys = ({
+ appId,
+ envId,
+ status = 200,
+ response = data.mockAPIKeysResponse(),
+}: MockFetchAPIKeysProps) => {
+ return nock(endpoint)
+ .get(`/app/${appId}/env/${envId}/api-keys`)
+ .reply(status, response);
+};
+
+interface MockDeleteAPIKeyProps {
+ appId: string;
+ envId: string;
+ keyId: string;
+ status?: number;
+}
+
+export const mockDeleteAPIKey = ({
+ appId,
+ envId,
+ keyId,
+ status = 200,
+}: MockDeleteAPIKeyProps) => {
+ return nock(endpoint)
+ .delete(`/app/${appId}/env/${envId}/api-key?keyId=${keyId}`)
+ .reply(status, { ok: true });
+};