Repository: keephq/keep
Pull request: feat: improve api key ux
<!-- 
Thanks for creating this pull request 🤗

Please make sure that the pull request is limited to one type (docs, feature, etc.) and keep it as small as possible. You can open multiple prs instead of opening a huge one.
-->

<!-- If this pull request closes an issue, please mention the issue number below -->
/claim #708
Closes #708


## 📑 Description
Will implement the following:

- [x] CRUD operations for API keys
- [x] UI update
- [x] Alert filtering in relation to the api key used to create them
- [x] API Key update upon usage

Will be complete by EOD Saturday January 27th.

## ✅ Checks
<!-- Make sure your pr passes the CI checks and do check the following fields as needed - -->
- [x] My pull request adheres to the code style of this project
- [ ] My code requires changes to the documentation (still have to check)
- [ ] I have updated the documentation as required
- [ ] All the tests have passed

## ℹ Additional Information

No breaking changes.


Problem statement:
API Key improvements
Improve API Key UX with:
UI 
Settings -> API Key
1. Improve UX with CRUD
2. Add name and details ("api key for team X")

Alerts
3. Add the ability to filter by api key in the UI (or make sure it works)

Backend
4. When pushing alerts via api key, save the relation so it can be later filtered

Hints from issue discussion:
/bounty 75
@shahargl can i get assigned
/attempt #708

<details id="algora-options">
  <summary>Options</summary>
  <ul>
    <li>
      <a href="https://console.algora.io/api/bounties/clrd8w4zs0006l00gwjydg1h6/cancel-attempt">
        Cancel my attempt
      </a>
    </li>
  </ul>
</details>

Yep
/attempt #708 I am in waitlist if @Min2who gives up

<details id="algora-options">
  <summary>Options</summary>
  <ul>
    <li>
      <a href="https://console.algora.io/api/bounties/clrd8w4zs0006l00gwjydg1h6/cancel-attempt">
        Cancel my attempt
      </a>
    </li>
  </ul>
</details>

I would really like to /attempt #708

<details id="algora-options">
  <summary>Options</summary>
  <ul>
    <li>
      <a href="https://console.algora.io/api/bounties/clrd8w4zs0006l00gwjydg1h6/cancel-attempt">
        Cancel my attempt
      </a>
    </li>
  </ul>
</details>

curious to fix the issue /attempt #708


<div id="algora" />

<details>
  <summary>Options</summary>
  <ul>
    <li>
      <a href="https://console.algora.io/api/bounties/clrd8w4zs0006l00gwjydg1h6/cancel-attempt">
        Cancel my attempt
      </a>
    </li>
  </ul>
</details>
@Min2who any progress? There are some other folks who wanna try 
can i get some time its been 1day only 

cc @shahargl @talboren 
> 1. Improve UX with CRUD

Hey @shahargl, I didn't get it what it means. Can you please provide a description of the points?

Commit message: feat(api): implement api key update function on alert creation

Patch to review:
diff --git a/keep-ui/app/settings/api-key-menu.tsx b/keep-ui/app/settings/api-key-menu.tsx
new file mode 100644
index 0000000000..990a3bde86
--- /dev/null
+++ b/keep-ui/app/settings/api-key-menu.tsx
@@ -0,0 +1,130 @@
+"use client";
+import { Menu, Transition, Portal } from "@headlessui/react";
+import { Fragment, useState} from "react";
+import { Bars3Icon } from "@heroicons/react/20/solid";
+import { Icon } from "@tremor/react";
+import { TrashIcon, UpdateIcon } from "@radix-ui/react-icons";
+import { getSession } from "next-auth/react";
+import { getApiURL } from "utils/apiUrl";
+import { User } from "./models";
+import { User as AuthUser } from "next-auth";
+import { mutate } from "swr";
+import { useFloating } from "@floating-ui/react-dom";
+
+
+interface Props {
+user?: User;
+  currentUser?: AuthUser;
+}
+
+export default function ApiKeysMenu({apiKeyId}: {apiKeyId: string}) {
+  const { refs, x, y } = useFloating();
+
+  const onRegenerate = async () => {
+    const confirmed = confirm(
+      "This action cannot be undone. This will revoke the key and generate a new one. Any further requests made with this key will fail. Make sure to update any applications that use this key."
+    );
+
+    if (confirmed) {
+      const session = await getSession();
+      const apiUrl = getApiURL();
+      const res = await fetch(`${apiUrl}/settings/apikey`, {
+        method: "PUT",
+        headers: {
+          Authorization: `Bearer ${session!.accessToken}`,
+          "Content-Type": "application/json",
+        },
+        body: JSON.stringify({'apiKeyId': apiKeyId})
+      });
+      if (res.ok) {
+        mutate(`${apiUrl}/settings/apikeys`);
+      }
+    }
+  };
+
+  const onDelete = async () => {
+    const confirmed = confirm(
+      "This action cannot be undone. This will permanently delete the API key and any future requests using this key will fail."
+    );
+
+    if (confirmed) {
+      const session = await getSession();
+      const apiUrl = getApiURL();
+      const res = await fetch(`${apiUrl}/settings/apikey/${apiKeyId}`, {
+        method: "DELETE",
+        headers: {
+          Authorization: `Bearer ${session!.accessToken}`,
+        },
+      });
+      if (res.ok) {
+        mutate(`${apiUrl}/settings/apikeys`);
+      }
+    }
+  };
+
+  return (
+    <Menu>
+    {({ open }) => (
+      <>
+        <Menu.Button ref={refs.setReference}>
+          <Icon
+            icon={Bars3Icon}
+            className="hover:bg-gray-100"
+            color="gray"
+          />
+        </Menu.Button>
+        {open && (
+          <Portal>
+            <div className="fixed inset-0" aria-hidden="true" />
+            <Transition
+              as={Fragment}
+              enter="transition ease-out duration-100"
+              enterFrom="transform opacity-0 scale-95"
+              enterTo="transform opacity-100 scale-100"
+              leave="transition ease-in duration-75"
+              leaveFrom="transform opacity-100 scale-100"
+              leaveTo="transform opacity-0 scale-95"
+            >
+              <Menu.Items
+                ref={refs.setFloating}
+                className="z-50 absolute mt-2 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
+                style={{ left: x ?? 0, top: y ?? 0 }}
+              >
+                <div className="px-1 py-1">
+                  <Menu.Item>
+                    {({ active }) => (
+                      <button
+                        onClick={onRegenerate}
+                        className={`${
+                          active ? "bg-slate-200" : "text-gray-900"
+                        } group flex w-full items-center rounded-md px-2 py-2 text-sm`}
+                      >
+                        <UpdateIcon className="mr-2 h-4 w-4" aria-hidden="true" />
+                       Roll key 
+                      </button>
+                    )}
+                  </Menu.Item>
+                  <Menu.Item>
+                    {({ active }) => (
+                      <button
+                        onClick={onDelete}
+                        className={`${
+                          active ? "bg-slate-200" : "text-gray-900"
+                        } group flex w-full items-center rounded-md px-2 py-2 text-sm`}
+                      >
+                        <TrashIcon className="mr-2 h-4 w-4" aria-hidden="true" />
+                        Delete
+                      </button>
+                    )}
+                  </Menu.Item>
+                </div>
+              </Menu.Items>
+            </Transition>
+          </Portal>
+        )}
+      </>
+    )}
+  </Menu>
+  );
+}
+
diff --git a/keep-ui/app/settings/api-key-settings.tsx b/keep-ui/app/settings/api-key-settings.tsx
index 528c6d9555..7675bbfc51 100644
--- a/keep-ui/app/settings/api-key-settings.tsx
+++ b/keep-ui/app/settings/api-key-settings.tsx
@@ -1,11 +1,29 @@
 "use client";
 
-import { Card, Title } from "@tremor/react";
+import { 
+    Card, 
+    Title, 
+    Subtitle, 
+    Button,
+    Table,
+    TableBody,
+    TableCell,
+    TableHead,
+    TableHeaderCell,
+    TableRow,
+    Text
+} from "@tremor/react";
 import Loading from "app/loading";
 import { CopyBlock, a11yLight } from "react-code-blocks";
 import useSWR from "swr";
 import { getApiURL } from "utils/apiUrl";
+import { KeyIcon } from "@heroicons/react/24/outline";
 import { fetcher } from "utils/fetcher";
+import { useState } from 'react';
+import { AuthenticationType } from "utils/authenticationType";
+import { Config } from "./users-settings";
+import CreateApiKeyModal from "./create-api-key-modal";
+import ApiKeysMenu from "./api-key-menu";
 
 interface ApiKeyResponse {
   apiKey: string;
@@ -19,29 +37,114 @@ interface Props {
 export default function ApiKeySettings({ accessToken, selectedTab }: Props) {
   const apiUrl = getApiURL();
   const { data, error, isLoading } = useSWR<ApiKeyResponse>(
-    selectedTab === "api-key" ? `${apiUrl}/settings/apikey` : null,
-    (url) => fetcher(url, accessToken),
+    selectedTab === "api-key" ? `${apiUrl}/settings/apikeys` : null,
+    async (url) => {
+        const response = await fetcher(url, accessToken);
+        setApiKeys(response.apiKeys);
+        return response;
+    },
     { revalidateOnFocus: false }
   );
 
+
+  const { data: configData } = useSWR<Config>("/api/config", fetcher, {
+    revalidateOnFocus: false,
+  });
+
+
+  const [isApiKeyModalOpen, setApiKeyModalOpen] = useState(false);
+  const [apiKeys, setApiKeys] = useState([]);
+
   if (isLoading) return <Loading />;
   if (error) return <div>{error.message}</div>;
 
-  const copyBlockApiKeyProps = {
-    theme: { ...a11yLight },
-    language: "text",
-    text: data?.apiKey || "",
-    codeBlock: true,
-    showLineNumbers: false,
-  };
+  const getCopyBlockProps = (secret: string) => {
+    return {
+        theme: { ...a11yLight },
+        language: "text",
+        text: secret,
+        codeBlock: true,
+        showLineNumbers: false,
+      }; 
+  }
+
+  // Determine runtime configuration
+  const authType = configData?.AUTH_TYPE as AuthenticationType;
+  // Create API key disabled if authType is none
+  const createApiKeyEnabled = authType !== AuthenticationType.NO_AUTH
 
   return (
     <div className="mt-10">
-      <Title>API Key</Title>
+      <div className="flex justify-between">
+        <div className="flex flex-col">
+          <Title>API Keys</Title>
+          <Subtitle>Add, edit or deactivate API keys from your tenant</Subtitle>
+        </div>
+
+        <div>
+          <Button
+            color="orange"
+            size="md"
+            icon={KeyIcon}
+            onClick={() => setApiKeyModalOpen(true)}
+            disabled={!createApiKeyEnabled}
+            tooltip={!createApiKeyEnabled ? "API Key creation is disabled because Keep is running in NO_AUTH mode.": "Add user"}
+          >
+            Create API key 
+          </Button>
+        </div>
+      </div>
       <Card className="mt-2.5">
+       {apiKeys.length ? (
+        <Table>
+          <TableHead>
+            <TableRow>
+              <TableHeaderCell>Name</TableHeaderCell>
+              <TableHeaderCell className="text-right">Key</TableHeaderCell>
+              <TableHeaderCell className="text-right">
+                Created At
+              </TableHeaderCell>
+              <TableHeaderCell className="text-right">
+                Last Used 
+              </TableHeaderCell>
+              <TableHeaderCell>
+              {/* Menu */}
+              </TableHeaderCell>
+            </TableRow>
+          </TableHead>
+          <TableBody>
+            {apiKeys.map((key) => (
+              <TableRow
+                key={key.reference_id}
+              >
+                <TableCell>{key.reference_id}</TableCell>
+                <TableCell className="text-right">
+                  <CopyBlock {...getCopyBlockProps(key.secret)} />
+                </TableCell>
+                <TableCell className="text-right">
+                  <Text>{key.created_at}</Text>
+                </TableCell>
+                <TableCell className="text-right">
+                  <Text>{key.last_used ?? "Never"}</Text>
+                </TableCell>
+                <TableCell>
+                    <ApiKeysMenu apiKeyId={key.reference_id}/>
+                </TableCell>
+              </TableRow>
+            ))}
+          </TableBody>
+        </Table>
+        ) : <div> There are no active api keys </div>}
         {/* Ensure CopyBlock is the only element within the card for proper spacing */}
-        <CopyBlock {...copyBlockApiKeyProps} />
       </Card>
+      <CreateApiKeyModal
+        isOpen={isApiKeyModalOpen}
+        onClose={() => setApiKeyModalOpen(false)}
+        authType={authType}
+        accessToken={accessToken}
+        setApiKeys={setApiKeys}
+        apiUrl={apiUrl}
+      />
     </div>
   );
 }
diff --git a/keep-ui/app/settings/create-api-key-modal.tsx b/keep-ui/app/settings/create-api-key-modal.tsx
new file mode 100644
index 0000000000..0426fae321
--- /dev/null
+++ b/keep-ui/app/settings/create-api-key-modal.tsx
@@ -0,0 +1,159 @@
+import React from 'react';
+import { useForm, Controller, SubmitHandler, FieldValues } from 'react-hook-form';
+import { Dialog } from "@headlessui/react";
+import { TextInput, Button, Subtitle, SearchSelect, SearchSelectItem, Icon } from "@tremor/react";
+import { AuthenticationType } from "utils/authenticationType";
+import { User } from "./models";
+import { getApiURL } from "utils/apiUrl";
+import { InfoCircledIcon } from "@radix-ui/react-icons";
+
+const roleOptions: RoleOption[] = [
+  {
+    value: "cli",
+    label: "CLI",
+    tooltip: "CLI has write alert ability",
+  },
+  {
+    value: "create_new",
+    label: "Create custom role",
+    isDisabled: true,
+    tooltip: "For custom roles, contact Keep team",
+  },
+];
+
+export default function CreateApiKeyModal({ isOpen, onClose, apiUrl, setApiKeys, accessToken }: any) {
+    const { handleSubmit, control, setError, clearErrors, reset, formState: { errors } } = useForm();
+
+    const onSubmit: SubmitHandler<FieldValues> = async (data) => {
+      try {
+        const response = await fetch(`${apiUrl}/settings/apikey`, {
+          method: "POST",
+          headers: {
+            Authorization: `Bearer ${accessToken}`,
+            "Content-Type": "application/json",
+          },
+          body: JSON.stringify(data),
+        });
+
+        if (response.ok) {
+          const newApiKey = await response.json();
+          setApiKeys(prevApiKeys => [...prevApiKeys, newApiKey]);
+          handleClose();
+        } else {
+          const errorData = await response.json();
+          // if 'detail' in errorData:
+          if (errorData.detail) {
+            setError('apiError', { type: 'manual', message: errorData.detail });
+          }
+          else{
+            setError('apiError', { type: 'manual', message: errorData.message || 'Failed to create API Key' });
+          }
+
+        }
+      } catch (error) {
+          console.log({error})
+        setError('apiError', { type: 'manual', message: 'An unexpected error occurred' });
+      }
+    };
+
+    const handleClose = () => {
+        clearErrors('apiError');
+        reset();
+        onClose();
+      };
+
+    return (
+      <Dialog as="div" className="fixed inset-0 z-10 overflow-y-auto" open={isOpen} onClose={handleClose}>
+        <div className="flex items-center justify-center min-h-screen">
+          <Dialog.Panel className="bg-white p-4 rounded" style={{ width: "400px", maxWidth: "90%" }}>
+            <Dialog.Title>Add User</Dialog.Title>
+            <form
+                onSubmit={e => {
+                    clearErrors()
+                    handleSubmit(onSubmit)(e)
+                }}
+            >
+              {/* Email/Username Field */}
+              {
+                <div className="mt-4">
+                  <Subtitle>Name</Subtitle>
+                  <Controller
+                    name="name"
+                    control={control}
+                    rules={{ required: 'Name is required' }}
+                    render={({ field }) => <TextInput {...field} error={!!errors.username} errorMessage={errors.username && typeof errors.username.message === 'string' ? errors.username.message : undefined}/>}
+                  />
+                </div>
+              }
+
+              {/* Role Field */}
+              <div className="mt-4">
+                <Subtitle>Role</Subtitle>
+                <Controller
+                    name="role"
+                    control={control}
+                    rules={{ required: 'Role is required' }}
+                    render={({ field: { onChange, value, ref } }) => (
+                        <>
+                        <SearchSelect
+                            placeholder="Select role"
+                            value={value}
+                            onValueChange={onChange}
+                            className={`rounded-lg border ${
+                            errors.role ? "border-red-500" : "border-transparent"
+                            }`}
+                            ref={ref}
+                        >
+                            {roleOptions.map(role => (
+                            <SearchSelectItem
+                                key={role.value}
+                                value={role.value}
+                                className={
+                                role.isDisabled ? "text-gray-400 cursor-not-allowed" : ""
+                                }
+                                onClick={(e) => {
+                                if (role.isDisabled) {
+                                    e.preventDefault();
+                                }
+                                }}
+                            >
+                                <div className="flex items-center">
+                                {role.label}
+                                {role.tooltip && (
+                                    <Icon
+                                    icon={InfoCircledIcon}
+                                    className="role-tooltip"
+                                    tooltip={role.tooltip}
+                                    color="gray"
+                                    size="xs"
+                                    />
+                                )}
+                                </div>
+                            </SearchSelectItem>
+                            ))}
+                        </SearchSelect>
+                        {errors.role && <div className="text-sm text-rose-500 mt-1">{errors.role.message?.toString()}</div>}
+                        </>
+                    )}
+                    />
+
+              </div>
+
+              {/* Display API Error */}
+              {errors.apiError && typeof errors.apiError.message === 'string' && (
+                <div className="text-red-500 mt-2">{errors.apiError.message}</div>
+              )}
+
+              {/* Submit and Cancel Buttons */}
+              <div className="mt-6 flex gap-2">
+                <Button color="orange" type="submit">Create API Key </Button>
+                <Button onClick={handleClose} variant="secondary" className="border border-orange-500 text-orange-500">Cancel</Button>
+              </div>
+            </form>
+          </Dialog.Panel>
+        </div>
+      </Dialog>
+    );
+  }
+
+
diff --git a/keep/api/core/dependencies.py b/keep/api/core/dependencies.py
index 0a6e28a2ea..314f87b8d0 100644
--- a/keep/api/core/dependencies.py
+++ b/keep/api/core/dependencies.py
@@ -35,6 +35,7 @@
 class AuthenticatedEntity:
     tenant_id: str
     email: str
+    api_key_name: Optional[str] = None
 
 
 def get_user_email(request: Request) -> str | None:
@@ -124,22 +125,23 @@ def extract_api_key(
     jwks_client = None
 
 
-def AuthVerifier(scopes: list[str] = []):
+def AuthVerifier(scopes: list[str] = [], returnApiKeyName: bool = False):
     # Determine the authentication type from the environment variable
     auth_type = os.environ.get("AUTH_TYPE", AuthenticationType.NO_AUTH.value)
 
     # Return the appropriate verifier based on the auth type
     if auth_type == AuthenticationType.MULTI_TENANT.value:
-        return AuthVerifierMultiTenant(scopes)
+        return AuthVerifierMultiTenant(scopes, returnApiKeyName)
     else:
-        return AuthVerifierSingleTenant(scopes)
+        return AuthVerifierSingleTenant(scopes, returnApiKeyName)
 
 
 class AuthVerifierMultiTenant:
     """Handles authentication and authorization for multi tenant mode"""
 
-    def __init__(self, scopes: list[str] = []) -> None:
+    def __init__(self, scopes: list[str] = [], returnApiKeyName: bool = False) -> None:
         self.scopes = scopes
+        self.returnApiKeyName = returnApiKeyName
 
     def _verify_bearer_token(
         self, token: str = Depends(oauth2_scheme)
@@ -217,6 +219,10 @@ def _verify_api_key(
                 detail=f"You don't have the required scopes to access this resource [required scopes: {self.scopes}]",
             )
         request.state.tenant_id = tenant_api_key.tenant_id
+
+        if self.returnApiKeyName:
+            return AuthenticatedEntity(tenant_api_key.tenant_id, tenant_api_key.created_by, tenant_api_key.reference_id)
+
         return AuthenticatedEntity(tenant_api_key.tenant_id, tenant_api_key.created_by)
 
     def __call__(
@@ -261,8 +267,9 @@ def __call__(
 class AuthVerifierSingleTenant:
     """Handles authentication and authorization for single tenant mode"""
 
-    def __init__(self, scopes: list[str] = []) -> None:
+    def __init__(self, scopes: list[str] = [], returnApiKeyName: bool = False) -> None:
         self.scopes = scopes
+        self.returnApiKeyName = returnApiKeyName
 
     def _verify_api_key(
         self,
@@ -291,6 +298,10 @@ def _verify_api_key(
                 detail=f"You don't have the required scopes to access this resource [required scopes: {self.scopes}]",
             )
         request.state.tenant_id = tenant_api_key.tenant_id
+
+        if self.returnApiKeyName:
+            return AuthenticatedEntity(tenant_api_key.tenant_id, tenant_api_key.created_by, tenant_api_key.reference_id)
+
         return AuthenticatedEntity(tenant_api_key.tenant_id, tenant_api_key.created_by)
 
     def _verify_bearer_token(
diff --git a/keep/api/models/alert.py b/keep/api/models/alert.py
index 1c5df5280f..8bca296514 100644
--- a/keep/api/models/alert.py
+++ b/keep/api/models/alert.py
@@ -52,6 +52,7 @@ class AlertDto(BaseModel):
     duplicateReason: str | None = None
     service: str | None = None
     source: list[str] | None = []
+    usedApiKey: bool = False
     message: str | None = None
     description: str | None = None
     pushed: bool = False  # Whether the alert was pushed or pulled from the provider
diff --git a/keep/api/models/db/tenant.py b/keep/api/models/db/tenant.py
index d19d66a86c..94880fb29c 100644
--- a/keep/api/models/db/tenant.py
+++ b/keep/api/models/db/tenant.py
@@ -1,5 +1,6 @@
 from typing import List, Optional
 from uuid import UUID, uuid4
+from datetime import datetime
 
 from sqlmodel import Field, Relationship, SQLModel
 
@@ -20,6 +21,8 @@ class TenantApiKey(SQLModel, table=True):
     system_description: Optional[str] = None
     created_by: str
     role: str
+    created_at: datetime = Field(default_factory=datetime.utcnow)
+    last_used: str = Field(default=None)
 
     class Config:
         orm_mode = True
diff --git a/keep/api/routes/alerts.py b/keep/api/routes/alerts.py
index 282771d0f9..eeecd181eb 100644
--- a/keep/api/routes/alerts.py
+++ b/keep/api/routes/alerts.py
@@ -28,6 +28,7 @@
 from keep.api.models.alert import AlertDto, DeleteRequestBody, EnrichAlertRequestBody
 from keep.api.models.db.alert import Alert, AlertRaw
 from keep.api.utils.email_utils import EmailTemplates, send_email
+from keep.api.utils.tenant_utils import update_key_last_used
 from keep.api.utils.enrichment_helpers import parse_and_enrich_deleted_and_assignees
 from keep.contextmanager.contextmanager import ContextManager
 from keep.providers.providers_factory import ProvidersFactory
@@ -587,7 +588,7 @@ def handle_formatted_events(
 async def receive_generic_event(
     alert: AlertDto | list[AlertDto],
     bg_tasks: BackgroundTasks,
-    authenticated_entity: AuthenticatedEntity = Depends(AuthVerifier(["write:alert"])),
+    authenticated_entity: AuthenticatedEntity = Depends(AuthVerifier(["write:alert"], True)),
     session: Session = Depends(get_session),
     pusher_client: Pusher = Depends(get_pusher_client),
 ):
@@ -607,7 +608,12 @@ async def receive_generic_event(
     for _alert in alert:
         # if not source, set it to keep
         if not _alert.source:
-            _alert.source = ["keep"]
+            if authenticated_entity.api_key_name:
+                _alert.source = [authenticated_entity.api_key_name]
+                _alert.usedApiKey = True
+            else:
+                _alert.source = ["keep"]
+
     bg_tasks.add_task(
         handle_formatted_events,
         tenant_id,
@@ -617,6 +623,16 @@ async def receive_generic_event(
         alert,
         pusher_client,
     )
+
+    if authenticated_entity.api_key_name:
+        logger.info("Updating API Key last used")
+        update_key_last_used(
+            session,
+            tenant_id,
+            unique_api_key_id=authenticated_entity.api_key_name
+        )
+        logger.info("Successfully updated API Key last used")
+
     return alert
 
 
diff --git a/keep/api/routes/settings.py b/keep/api/routes/settings.py
index 0d496206db..cfe045e25b 100644
--- a/keep/api/routes/settings.py
+++ b/keep/api/routes/settings.py
@@ -7,7 +7,7 @@
 from email.mime.text import MIMEText
 from typing import Optional, Tuple
 
-from fastapi import APIRouter, Body, Depends, HTTPException
+from fastapi import APIRouter, Body, Depends, HTTPException, Request
 from fastapi.responses import JSONResponse
 from pydantic import BaseModel, Field
 from sqlmodel import Session
@@ -24,7 +24,7 @@
 from keep.api.models.user import User
 from keep.api.models.webhook import WebhookSettings
 from keep.api.utils.auth0_utils import getAuth0Client
-from keep.api.utils.tenant_utils import get_or_create_api_key
+from keep.api.utils.tenant_utils import get_api_keys_secret, get_or_create_api_key, update_api_key_internal, delete_api_key_internal, create_api_key, get_api_keys
 from keep.contextmanager.contextmanager import ContextManager
 from keep.secretmanager.secretmanagerfactory import SecretManagerFactory
 
@@ -351,23 +351,115 @@ def _print_debug(self, *args):
             super()._print_debug(*args)
 
 
-@router.get("/apikey")
-def get_api_key(
+@router.post("/apikey", description="Create API key")
+async def create_key(
+    request: Request,
+    authenticated_entity: AuthenticatedEntity = Depends(
+        AuthVerifier(["write:settings"])
+    ),
+    session: Session = Depends(get_session),
+):
+    try:
+        body = await request.json()
+        unique_api_key_id = body['name']
+    except Exception:
+        raise HTTPException(status_code=400, detail="Invalid request body")
+
+    api_key = create_api_key(
+        session=session,
+        tenant_id=authenticated_entity.tenant_id,
+        created_by=authenticated_entity.email,
+        unique_api_key_id=unique_api_key_id,
+        role=AdminRole,
+        is_system=False,
+    )
+
+    return api_key
+
+
+@router.get("/apikeys", description="Get API keys")
+def get_keys(
     authenticated_entity: AuthenticatedEntity = Depends(
         AuthVerifier(["read:settings"])
     ),
     session: Session = Depends(get_session),
 ):
-    logger.info("Getting API key")
     tenant_id = authenticated_entity.tenant_id
-    user_name = authenticated_entity.email
-    # get the api key for the CLI
-    api_key = get_or_create_api_key(
+
+    logger.info(f"Getting active API keys for tenant {tenant_id}")
+
+    api_keys = get_api_keys(
+        session=session,
+        tenant_id=tenant_id,
+    )
+
+    if api_keys:
+        api_keys = get_api_keys_secret(
+            tenant_id=tenant_id,
+            api_keys=api_keys
+        )
+
+    logger.info(
+        f"Active API keys for tenant {tenant_id} retrieved successfully",
+    )
+
+    return {"apiKeys": api_keys}
+
+
+@router.put("/apikey", description="Update API key secret")
+async def update_api_key(
+    request: Request,
+    authenticated_entity: AuthenticatedEntity = Depends(
+        AuthVerifier(["write:settings"])
+    ),
+    session: Session = Depends(get_session),
+):
+
+    try:
+        body = await request.json()
+        unique_api_key_id = body['apiKeyId']
+    except Exception:
+        raise HTTPException(status_code=400, detail="Invalid request body")
+
+    tenant_id = authenticated_entity.tenant_id
+
+    logger.info(
+        f"Updating API key ({unique_api_key_id}) secret",
+        extra={"tenant_id": tenant_id, "unique_api_key_id": unique_api_key_id},
+    )
+
+    api_key = update_api_key_internal(
         session=session,
         tenant_id=tenant_id,
-        created_by=user_name,
-        unique_api_key_id="cli",
-        system_description="API key",
+        unique_api_key_id=unique_api_key_id,
     )
-    logger.info("API key retrieved successfully")
-    return {"apiKey": api_key}
+
+    if api_key:
+        logger.info(f"Api key ({unique_api_key_id}) secret updated")
+        return {"message": "API key secret updated", "apiKey": api_key}
+    else:
+        logger.info(f"Api key ({unique_api_key_id}) not found")
+        raise HTTPException(status_code=404, detail=f"API key ({unique_api_key_id}) not found")
+
+
+@router.delete("/apikey/{keyId}", description="Delete API key")
+def delete_api_key(
+    keyId: str,
+    authenticated_entity: AuthenticatedEntity = Depends(
+        AuthVerifier(["write:settings"])
+    ),
+    session: Session = Depends(get_session),
+):
+    logger.info(f"Deleting api key ({keyId})")
+
+    if delete_api_key_internal(
+        session=session,
+        tenant_id=authenticated_entity.tenant_id,
+        unique_api_key_id=keyId,
+
+    ):
+        logger.info(f"Api key ({keyId}) deleted")
+        return {"message": "Api key deleted"}
+    else:
+        logger.info(f"Api key ({keyId}) not found")
+        raise HTTPException(status_code=404, detail=f"Api key ({keyId}) not found")
diff --git a/keep/api/utils/tenant_utils.py b/keep/api/utils/tenant_utils.py
index a4e93cefe9..c1ced9ccb0 100644
--- a/keep/api/utils/tenant_utils.py
+++ b/keep/api/utils/tenant_utils.py
@@ -2,6 +2,7 @@
 import logging
 from typing import Optional
 from uuid import uuid4
+from datetime import datetime
 
 from sqlmodel import Session, select
 
@@ -15,6 +16,131 @@
 logger = logging.getLogger(__name__)
 
 
+def delete_api_key_internal(
+    session: Session,
+    tenant_id: str,
+    unique_api_key_id: str,
+) -> str:
+    """
+    Deletes API key for the given tenant.
+
+    Args:
+        session (Session): _description_
+        tenant_id (str): _description_
+        unique_api_key_id (str): _description_
+
+    Returns:
+        str: _description_
+    """
+    # Find API key
+    statement = (
+        select(TenantApiKey)
+        .where(TenantApiKey.reference_id == unique_api_key_id)
+        .where(TenantApiKey.tenant_id == tenant_id)
+    )
+
+    api_key = session.exec(statement).first()
+
+    if api_key:
+        # Delete from database
+        session.delete(api_key)
+        session.commit()
+
+        # Delete from secret manager
+        context_manager = ContextManager(tenant_id=tenant_id)
+        secret_manager = SecretManagerFactory.get_secret_manager(context_manager)
+
+        secret_manager.delete_secret(
+            secret_name=f"{tenant_id}-{unique_api_key_id}",
+        )
+
+        return True
+    return False
+
+
+def update_key_last_used(
+    session: Session,
+    tenant_id: str,
+    unique_api_key_id: str,
+) -> str:
+    """
+    Updates API key last used.
+
+    Args:
+        session (Session): _description_
+        tenant_id (str): _description_
+        unique_api_key_id (str): _description_
+
+    Returns:
+        str: _description_
+    """
+
+    # Get API Key from database
+    statement = (
+        select(TenantApiKey)
+        .where(TenantApiKey.reference_id == unique_api_key_id)
+        .where(TenantApiKey.tenant_id == tenant_id)
+    )
+
+    tenant_api_key_entry = session.exec(statement).first()
+
+    # Update last used
+    tenant_api_key_entry.last_used = datetime.utcnow()
+    session.commit()
+
+
+def update_api_key_internal(
+    session: Session,
+    tenant_id: str,
+    unique_api_key_id: str,
+) -> str:
+    """
+    Updates API key secret for the given tenant.
+
+    Args:
+        session (Session): _description_
+        tenant_id (str): _description_
+        unique_api_key_id (str): _description_
+
+    Returns:
+        str: _description_
+    """
+
+    # Get API Key from database
+    statement = (
+        select(TenantApiKey)
+        .where(TenantApiKey.reference_id == unique_api_key_id)
+        .where(TenantApiKey.tenant_id == tenant_id)
+    )
+
+    tenant_api_key_entry = session.exec(statement).first()
+
+    # If no APIkey is found return
+    if not tenant_api_key_entry:
+        return False
+    else:
+        # Find current API key in secret_manager
+        context_manager = ContextManager(tenant_id=tenant_id)
+        secret_manager = SecretManagerFactory.get_secret_manager(context_manager)
+        old_api_key_secret = secret_manager.read_secret(
+                f"{tenant_id}-{unique_api_key_id}"
+        )
+
+        # Update API key in secret_manager
+        api_key = str(uuid4())
+
+        secret_manager.write_secret(
+            secret_name=f"{tenant_id}-{unique_api_key_id}",
+            secret_value=api_key,
+        )
+
+        # Update API key hash in DB
+        tenant_api_key_entry.key_hash = hashlib.sha256(api_key.encode("utf-8")).hexdigest()
+        session.commit()
+
+        return api_key
+
+
 def create_api_key(
     session: Session,
     tenant_id: str,
@@ -45,6 +171,7 @@ def create_api_key(
     )
     api_key = str(uuid4())
     hashed_api_key = hashlib.sha256(api_key.encode("utf-8")).hexdigest()
+
     # Save the api key in the secret manager
     context_manager = ContextManager(tenant_id=tenant_id)
     secret_manager = SecretManagerFactory.get_secret_manager(context_manager)
@@ -63,13 +190,69 @@ def create_api_key(
         role=role.get_name(),
     )
     session.add(new_installation_api_key)
+
     if commit:
         session.commit()
+
     logger.info(
         "Created API key",
         extra={"tenant_id": tenant_id, "unique_api_key_id": unique_api_key_id},
     )
-    return api_key
+
+    if is_system:
+        return api_key
+
+    else:
+        statement = (
+            select(TenantApiKey)
+            .where(TenantApiKey.tenant_id == tenant_id)
+            .where(TenantApiKey.reference_id == new_installation_api_key.reference_id)
+        )
+
+        new_api_key = session.exec(statement).first()
+        return {**vars(new_api_key), "secret": api_key}
+
+
+def get_api_keys(
+    session: Session,
+    tenant_id: str,
+) -> [TenantApiKey]:
+    """
+    Gets all active API keys for the given tenant.
+
+    Args:
+        session (Session): _description_
+        tenant_id (str): _description_
+
+    Returns:
+        str: _description_
+    """
+    statement = (
+        select(TenantApiKey)
+        .where(TenantApiKey.tenant_id == tenant_id)
+    )
+
+    api_keys = session.exec(statement).all()
+
+    return api_keys
+
+
+def get_api_keys_secret(
+    tenant_id,
+    api_keys,
+):
+    context_manager = ContextManager(tenant_id=tenant_id)
+    secret_manager = SecretManagerFactory.get_secret_manager(context_manager)
+    api_keys_with_secret = []
+    for api_key in api_keys:
+        if api_key.reference_id == "webhook":
+            continue
+        secret = secret_manager.read_secret(
+                f"{api_key.tenant_id}-{api_key.reference_id}"
+        )
+        api_keys_with_secret.append({**vars(api_key), "secret": secret})
+
+    return api_keys_with_secret
 
 
 def get_or_create_api_key(
@@ -78,7 +261,7 @@ def get_or_create_api_key(
     created_by: str,
     unique_api_key_id: str,
     system_description: Optional[str] = None,
-) -> str:
+):
     """
     Gets or creates an API key for the given tenant.
 


Code review comments:
diff_hunk: @@ -0,0 +1,159 @@
+import React from 'react';
+import { useForm, Controller, SubmitHandler, FieldValues } from 'react-hook-form';
+import { Dialog } from "@headlessui/react";
+import { TextInput, Button, Subtitle, SearchSelect, SearchSelectItem, Icon } from "@tremor/react";
+import { AuthenticationType } from "utils/authenticationType";
+import { User } from "./models";
+import { getApiURL } from "utils/apiUrl";
+import { InfoCircledIcon } from "@radix-ui/react-icons";
+
+const roleOptions: RoleOption[] = [
+  {
+    value: "cli",
path: keep-ui/app/settings/create-api-key-modal.tsx
text: @user1:
Add "webhook" role which is "write alert" ability

@author:
fix: 5d26d9669eb1b31915e792d0cf7ab263623f62b3

diff_hunk: @@ -617,6 +623,16 @@ async def receive_generic_event(
         alert,
         pusher_client,
     )
+
+    if authenticated_entity.api_key_name:
+        logger.info("Updating API Key last used")
+        update_key_last_used(
+            session,
+            tenant_id,
+            unique_api_key_id=authenticated_entity.api_key_name
+        )
+        logger.info("Successfully updated API Key last used")
path: keep/api/routes/alerts.py
text: @user1:
change to debug

@author:
fixed by d3c5e5ed23035e56ae8a9354499d6521451917e6

diff_hunk: @@ -351,23 +351,115 @@ def _print_debug(self, *args):
             super()._print_debug(*args)
 
 
-@router.get("/apikey")
-def get_api_key(
+@router.post("/apikey", description="Create API key")
+async def create_key(
+    request: Request,
+    authenticated_entity: AuthenticatedEntity = Depends(
+        AuthVerifier(["write:settings"])
+    ),
+    session: Session = Depends(get_session),
+):
+    try:
+        body = await request.json()
+        unique_api_key_id = body['name']
+    except Exception:
+        raise HTTPException(status_code=400, detail="Invalid request body")
+
+    api_key = create_api_key(
+        session=session,
+        tenant_id=authenticated_entity.tenant_id,
+        created_by=authenticated_entity.email,
+        unique_api_key_id=unique_api_key_id,
+        role=AdminRole,
path: keep/api/routes/settings.py
text: @user1:
the role should be taken from the request and not be static

@author:
Fix: f72cdc80c1540a13e0d365ca0c5f59b9e59dabbe

diff_hunk: @@ -351,23 +351,115 @@ def _print_debug(self, *args):
             super()._print_debug(*args)
 
 
-@router.get("/apikey")
-def get_api_key(
+@router.post("/apikey", description="Create API key")
+async def create_key(
+    request: Request,
+    authenticated_entity: AuthenticatedEntity = Depends(
+        AuthVerifier(["write:settings"])
+    ),
+    session: Session = Depends(get_session),
+):
+    try:
+        body = await request.json()
+        unique_api_key_id = body['name']
+    except Exception:
+        raise HTTPException(status_code=400, detail="Invalid request body")
+
+    api_key = create_api_key(
+        session=session,
+        tenant_id=authenticated_entity.tenant_id,
+        created_by=authenticated_entity.email,
+        unique_api_key_id=unique_api_key_id,
+        role=AdminRole,
+        is_system=False,
+    )
+
+    return api_key
+
+
+@router.get("/apikeys", description="Get API keys")
+def get_keys(
path: keep/api/routes/settings.py
text: @user1:
the logic should be something like:
1. if admin - show all API keys
2. if not admin - show all API keys but get the secrets only on the keys created by this user


@author:
fix: 
- b95d6a54d5e8ab2a6354a1b61b15dcca87c4f7dd
- 1fd33206eaf29b579d264a3f944a8d5d85841c32
- 9d9f0bba4ee5ccedb75aa9057300140b8a29f876

diff_hunk: @@ -19,29 +37,114 @@ interface Props {
 export default function ApiKeySettings({ accessToken, selectedTab }: Props) {
   const apiUrl = getApiURL();
   const { data, error, isLoading } = useSWR<ApiKeyResponse>(
-    selectedTab === "api-key" ? `${apiUrl}/settings/apikey` : null,
-    (url) => fetcher(url, accessToken),
+    selectedTab === "api-key" ? `${apiUrl}/settings/apikeys` : null,
+    async (url) => {
+        const response = await fetcher(url, accessToken);
+        setApiKeys(response.apiKeys);
+        return response;
+    },
     { revalidateOnFocus: false }
   );
 
+
+  const { data: configData } = useSWR<Config>("/api/config", fetcher, {
+    revalidateOnFocus: false,
+  });
+
+
+  const [isApiKeyModalOpen, setApiKeyModalOpen] = useState(false);
+  const [apiKeys, setApiKeys] = useState([]);
+
   if (isLoading) return <Loading />;
   if (error) return <div>{error.message}</div>;
 
-  const copyBlockApiKeyProps = {
-    theme: { ...a11yLight },
-    language: "text",
-    text: data?.apiKey || "",
-    codeBlock: true,
-    showLineNumbers: false,
-  };
+  const getCopyBlockProps = (secret: string) => {
+    return {
+        theme: { ...a11yLight },
+        language: "text",
+        text: secret,
+        codeBlock: true,
+        showLineNumbers: false,
+      }; 
+  }
+
+  // Determine runtime configuration
+  const authType = configData?.AUTH_TYPE as AuthenticationType;
+  // Create API key disabled if authType is none
+  const createApiKeyEnabled = authType !== AuthenticationType.NO_AUTH
 
   return (
     <div className="mt-10">
-      <Title>API Key</Title>
+      <div className="flex justify-between">
+        <div className="flex flex-col">
+          <Title>API Keys</Title>
+          <Subtitle>Add, edit or deactivate API keys from your tenant</Subtitle>
+        </div>
+
+        <div>
+          <Button
+            color="orange"
+            size="md"
+            icon={KeyIcon}
+            onClick={() => setApiKeyModalOpen(true)}
+            disabled={!createApiKeyEnabled}
+            tooltip={!createApiKeyEnabled ? "API Key creation is disabled because Keep is running in NO_AUTH mode.": "Add user"}
+          >
+            Create API key 
+          </Button>
+        </div>
+      </div>
       <Card className="mt-2.5">
+       {apiKeys.length ? (
+        <Table>
+          <TableHead>
+            <TableRow>
+              <TableHeaderCell>Name</TableHeaderCell>
+              <TableHeaderCell className="text-right">Key</TableHeaderCell>
path: keep-ui/app/settings/api-key-settings.tsx
text: @user1:
Align the text to the left, now its centered:
![CleanShot 2024-01-29 at 14 23 48](https://github.com/keephq/keep/assets/12069200/a27e1371-babd-4239-acbc-c8068acbed26)


@author:
im a bit confused by this. I think you're talking about the Name header cell.

Will align to the left but all the other table headers are aligned to the right.

fix: 826f4f740f44b4d0025d8a3e73baadd07a1f696e

diff_hunk: @@ -19,29 +37,114 @@ interface Props {
 export default function ApiKeySettings({ accessToken, selectedTab }: Props) {
   const apiUrl = getApiURL();
   const { data, error, isLoading } = useSWR<ApiKeyResponse>(
-    selectedTab === "api-key" ? `${apiUrl}/settings/apikey` : null,
-    (url) => fetcher(url, accessToken),
+    selectedTab === "api-key" ? `${apiUrl}/settings/apikeys` : null,
+    async (url) => {
+        const response = await fetcher(url, accessToken);
+        setApiKeys(response.apiKeys);
+        return response;
+    },
     { revalidateOnFocus: false }
   );
 
+
+  const { data: configData } = useSWR<Config>("/api/config", fetcher, {
+    revalidateOnFocus: false,
+  });
+
+
+  const [isApiKeyModalOpen, setApiKeyModalOpen] = useState(false);
+  const [apiKeys, setApiKeys] = useState([]);
+
   if (isLoading) return <Loading />;
   if (error) return <div>{error.message}</div>;
 
-  const copyBlockApiKeyProps = {
-    theme: { ...a11yLight },
-    language: "text",
-    text: data?.apiKey || "",
-    codeBlock: true,
-    showLineNumbers: false,
-  };
+  const getCopyBlockProps = (secret: string) => {
+    return {
+        theme: { ...a11yLight },
+        language: "text",
+        text: secret,
+        codeBlock: true,
+        showLineNumbers: false,
+      }; 
+  }
+
+  // Determine runtime configuration
+  const authType = configData?.AUTH_TYPE as AuthenticationType;
+  // Create API key disabled if authType is none
+  const createApiKeyEnabled = authType !== AuthenticationType.NO_AUTH
 
   return (
     <div className="mt-10">
-      <Title>API Key</Title>
+      <div className="flex justify-between">
+        <div className="flex flex-col">
+          <Title>API Keys</Title>
+          <Subtitle>Add, edit or deactivate API keys from your tenant</Subtitle>
+        </div>
+
+        <div>
+          <Button
+            color="orange"
+            size="md"
+            icon={KeyIcon}
+            onClick={() => setApiKeyModalOpen(true)}
+            disabled={!createApiKeyEnabled}
+            tooltip={!createApiKeyEnabled ? "API Key creation is disabled because Keep is running in NO_AUTH mode.": "Add user"}
+          >
+            Create API key 
+          </Button>
+        </div>
+      </div>
       <Card className="mt-2.5">
+       {apiKeys.length ? (
+        <Table>
+          <TableHead>
+            <TableRow>
+              <TableHeaderCell>Name</TableHeaderCell>
+              <TableHeaderCell className="text-right">Key</TableHeaderCell>
+              <TableHeaderCell className="text-right">
+                Created At
+              </TableHeaderCell>
+              <TableHeaderCell className="text-right">
+                Last Used 
+              </TableHeaderCell>
+              <TableHeaderCell>
+              {/* Menu */}
+              </TableHeaderCell>
+            </TableRow>
+          </TableHead>
+          <TableBody>
+            {apiKeys.map((key) => (
+              <TableRow
+                key={key.reference_id}
+              >
+                <TableCell>{key.reference_id}</TableCell>
+                <TableCell className="text-right">
+                  <CopyBlock {...getCopyBlockProps(key.secret)} />
path: keep-ui/app/settings/api-key-settings.tsx
text: @user1:
There is a little issue when I'm clicking on "copy" on more than on API key (only one should be shown as copied):
![CleanShot 2024-01-29 at 14 25 51](https://github.com/keephq/keep/assets/12069200/6dc2c0fa-8f1e-4e71-9910-cd417997481f)


@author:
Looks like CopyBlock component [handles state internally](https://github.com/rajinwonderland/react-code-blocks/blob/master/packages/react-code-blocks/src/components/CopyBlock.tsx#L80). 

Should I look into patching react-code-blocks? I know some people don't like to do that.

diff_hunk: @@ -15,6 +16,131 @@
 logger = logging.getLogger(__name__)
 
 
+def delete_api_key_internal(
+    session: Session,
+    tenant_id: str,
+    unique_api_key_id: str,
+) -> str:
+    """
+    Deletes API key for the given tenant.
+
+    Args:
+        session (Session): _description_
+        tenant_id (str): _description_
+        unique_api_key_id (str): _description_
+
+    Returns:
+        str: _description_
+    """
+    # Find API key
+    statement = (
+        select(TenantApiKey)
+        .where(TenantApiKey.reference_id == unique_api_key_id)
+        .where(TenantApiKey.tenant_id == tenant_id)
+    )
+
+    api_key = session.exec(statement).first()
+
+    if api_key:
+        # Delete from database
+        session.delete(api_key)
path: keep/api/utils/tenant_utils.py
text: @user1:
don't delete but add a "is_deleted" column and change it to true. the reason is that we want to be able to keep track of the alerts that were written by API key that was deleted

@author:
fix: f72cdc80c1540a13e0d365ca0c5f59b9e59dabbe

diff_hunk: @@ -607,7 +608,12 @@ async def receive_generic_event(
     for _alert in alert:
         # if not source, set it to keep
         if not _alert.source:
-            _alert.source = ["keep"]
+            if authenticated_entity.api_key_name:
+                _alert.source = [authenticated_entity.api_key_name]
path: keep/api/routes/alerts.py
text: @user1:
two things - 
1. The source should be still "keep". Add another column to alerts that will reference to the API key
2. usedApiKey is redundant - just use one column "api_key_ref" so we have the reference to the api key

@author:
fixed e1272835edd0127b3517106ed576607ca4f1c770

diff_hunk: @@ -617,6 +623,16 @@ async def receive_generic_event(
         alert,
         pusher_client,
     )
+
+    if authenticated_entity.api_key_name:
+        logger.info("Updating API Key last used")
path: keep/api/routes/alerts.py
text: @user1:
change to debug


@author:
fixed by d3c5e5ed23035e56ae8a9354499d6521451917e6