From 1bf90129928694e0b8b9dc71d3f62135ecb3e9c4 Mon Sep 17 00:00:00 2001
From: Hemachandar <132386067+hmacr@users.noreply.github.com>
Date: Thu, 20 Jun 2024 18:48:35 +0530
Subject: [PATCH] feat: Add lastUsedAt to API keys (#7082)
* feat: Add lastUsedAt to API keys
* rename column to lastActiveAt
* switch order
---
app/models/ApiKey.ts | 6 +++++
.../Settings/components/ApiKeyListItem.tsx | 6 +++++
server/middlewares/authentication.ts | 2 ++
...240618201908-add-lastActiveAt-to-apikey.js | 15 +++++++++++++
server/models/ApiKey.test.ts | 22 +++++++++++++++++++
server/models/ApiKey.ts | 17 ++++++++++++++
server/presenters/apiKey.ts | 1 +
server/routes/api/apiKeys/apiKeys.test.ts | 2 ++
shared/i18n/locales/en_US/translation.json | 1 +
9 files changed, 72 insertions(+)
create mode 100644 server/migrations/20240618201908-add-lastActiveAt-to-apikey.js
diff --git a/app/models/ApiKey.ts b/app/models/ApiKey.ts
index dddeddaf6a..b624828011 100644
--- a/app/models/ApiKey.ts
+++ b/app/models/ApiKey.ts
@@ -24,6 +24,12 @@ class ApiKey extends Model {
@observable
expiresAt?: string;
+ /**
+ * An optional datetime that the API key was last used at.
+ */
+ @observable
+ lastActiveAt?: string;
+
secret: string;
/**
diff --git a/app/scenes/Settings/components/ApiKeyListItem.tsx b/app/scenes/Settings/components/ApiKeyListItem.tsx
index b60126f31a..243d50d350 100644
--- a/app/scenes/Settings/components/ApiKeyListItem.tsx
+++ b/app/scenes/Settings/components/ApiKeyListItem.tsx
@@ -27,6 +27,12 @@ const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
{t(`Created`)} ·{" "}
+ {apiKey.lastActiveAt && (
+
+ {t("Last used")} {" "}
+ ·{" "}
+
+ )}
{apiKey.expiresAt
? dateToExpiry(apiKey.expiresAt, t, userLocale)
diff --git a/server/middlewares/authentication.ts b/server/middlewares/authentication.ts
index 65fead0310..bd0557865b 100644
--- a/server/middlewares/authentication.ts
+++ b/server/middlewares/authentication.ts
@@ -100,6 +100,8 @@ export default function auth(options: AuthenticationOptions = {}) {
if (!user) {
throw AuthenticationError("Invalid API key");
}
+
+ await apiKey.updateActiveAt();
} else {
type = AuthenticationType.APP;
user = await getUserForJWT(String(token));
diff --git a/server/migrations/20240618201908-add-lastActiveAt-to-apikey.js b/server/migrations/20240618201908-add-lastActiveAt-to-apikey.js
new file mode 100644
index 0000000000..5ab0896d4b
--- /dev/null
+++ b/server/migrations/20240618201908-add-lastActiveAt-to-apikey.js
@@ -0,0 +1,15 @@
+"use strict";
+
+/** @type {import('sequelize-cli').Migration} */
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.addColumn("apiKeys", "lastActiveAt", {
+ type: Sequelize.DATE,
+ allowNull: true,
+ });
+ },
+
+ async down(queryInterface, Sequelize) {
+ await queryInterface.removeColumn("apiKeys", "lastActiveAt");
+ },
+};
diff --git a/server/models/ApiKey.test.ts b/server/models/ApiKey.test.ts
index cd033ee77e..29f26f972c 100644
--- a/server/models/ApiKey.test.ts
+++ b/server/models/ApiKey.test.ts
@@ -17,4 +17,26 @@ describe("#ApiKey", () => {
expect(ApiKey.match("1234567890")).toBe(false);
});
});
+
+ describe("lastActiveAt", () => {
+ test("should update lastActiveAt", async () => {
+ const apiKey = await buildApiKey({
+ name: "Dev",
+ });
+ await apiKey.updateActiveAt();
+ expect(apiKey.lastActiveAt).toBeTruthy();
+ });
+
+ test("should not update lastActiveAt within 5 minutes", async () => {
+ const apiKey = await buildApiKey({
+ name: "Dev",
+ });
+ await apiKey.updateActiveAt();
+ expect(apiKey.lastActiveAt).toBeTruthy();
+
+ const lastActiveAt = apiKey.lastActiveAt;
+ await apiKey.updateActiveAt();
+ expect(apiKey.lastActiveAt).toEqual(lastActiveAt);
+ });
+ });
});
diff --git a/server/models/ApiKey.ts b/server/models/ApiKey.ts
index d780a86cce..9e9c9ebde3 100644
--- a/server/models/ApiKey.ts
+++ b/server/models/ApiKey.ts
@@ -1,3 +1,4 @@
+import { subMinutes } from "date-fns";
import randomstring from "randomstring";
import { InferAttributes, InferCreationAttributes } from "sequelize";
import {
@@ -39,6 +40,10 @@ class ApiKey extends ParanoidModel<
@Column
expiresAt: Date | null;
+ @IsDate
+ @Column
+ lastActiveAt: Date | null;
+
// hooks
@BeforeValidate
@@ -67,6 +72,18 @@ class ApiKey extends ParanoidModel<
@ForeignKey(() => User)
@Column
userId: string;
+
+ updateActiveAt = async () => {
+ const fiveMinutesAgo = subMinutes(new Date(), 5);
+
+ // ensure this is updated only every few minutes otherwise
+ // we'll be constantly writing to the DB as API requests happen
+ if (!this.lastActiveAt || this.lastActiveAt < fiveMinutesAgo) {
+ this.lastActiveAt = new Date();
+ }
+
+ return this.save();
+ };
}
export default ApiKey;
diff --git a/server/presenters/apiKey.ts b/server/presenters/apiKey.ts
index 498aa2262e..ef331cfc29 100644
--- a/server/presenters/apiKey.ts
+++ b/server/presenters/apiKey.ts
@@ -8,5 +8,6 @@ export default function presentApiKey(key: ApiKey) {
createdAt: key.createdAt,
updatedAt: key.updatedAt,
expiresAt: key.expiresAt,
+ lastActiveAt: key.lastActiveAt,
};
}
diff --git a/server/routes/api/apiKeys/apiKeys.test.ts b/server/routes/api/apiKeys/apiKeys.test.ts
index ea15d6d182..d733b87049 100644
--- a/server/routes/api/apiKeys/apiKeys.test.ts
+++ b/server/routes/api/apiKeys/apiKeys.test.ts
@@ -20,6 +20,7 @@ describe("#apiKeys.create", () => {
expect(res.status).toEqual(200);
expect(body.data.name).toEqual("My API Key");
expect(body.data.expiresAt).toEqual(now.toISOString());
+ expect(body.data.lastActiveAt).toBeNull();
});
it("should allow creating an api key without expiry", async () => {
@@ -36,6 +37,7 @@ describe("#apiKeys.create", () => {
expect(res.status).toEqual(200);
expect(body.data.name).toEqual("My API Key");
expect(body.data.expiresAt).toBeNull();
+ expect(body.data.lastActiveAt).toBeNull();
});
it("should require authentication", async () => {
diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json
index c7454d946c..4f74121aad 100644
--- a/shared/i18n/locales/en_US/translation.json
+++ b/shared/i18n/locales/en_US/translation.json
@@ -768,6 +768,7 @@
"API key copied to clipboard": "API key copied to clipboard",
"Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the developer documentation.": "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the developer documentation.",
"Personal keys": "Personal keys",
+ "Last used": "Last used",
"No expiry": "No expiry",
"Copied": "Copied",
"Revoking": "Revoking",