diff --git a/next.config.ts b/next.config.ts
index 467703f29..ce134be8f 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -208,6 +208,9 @@ if (isProd && cloudMode) {
/** @type {import('next').NextConfig} */
export default withNextIntl({
reactStrictMode: false,
+ devIndicators: {
+ position: 'bottom-right',
+ },
env: {
apiUrl,
basePath,
diff --git a/prisma/migrations/26_add_api_key/migration.sql b/prisma/migrations/26_add_api_key/migration.sql
new file mode 100644
index 000000000..31085ac62
--- /dev/null
+++ b/prisma/migrations/26_add_api_key/migration.sql
@@ -0,0 +1,21 @@
+-- CreateTable
+CREATE TABLE "api_key" (
+ "api_key_id" UUID NOT NULL,
+ "user_id" UUID NOT NULL,
+ "name" VARCHAR(255) NOT NULL,
+ "key_hash" VARCHAR(128) NOT NULL,
+ "key_prefix" VARCHAR(20) NOT NULL,
+ "last_used_at" TIMESTAMPTZ(6),
+ "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "api_key_pkey" PRIMARY KEY ("api_key_id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "api_key_key_hash_key" ON "api_key"("key_hash");
+
+-- CreateIndex
+CREATE INDEX "api_key_user_id_idx" ON "api_key"("user_id");
+
+-- AddForeignKey
+ALTER TABLE "api_key" ADD CONSTRAINT "api_key_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("user_id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 113c67e99..a57873235 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -32,10 +32,26 @@ model User {
twoFactorBackupCodes TwoFactorBackupCode[]
twoFactorOtpUseds TwoFactorOtpUsed[]
twoFactorRateLimit TwoFactorRateLimit?
+ apiKeys ApiKey[]
@@map("user")
}
+model ApiKey {
+ id String @id() @map("api_key_id") @db.Uuid
+ userId String @map("user_id") @db.Uuid
+ name String @db.VarChar(255)
+ keyHash String @unique @map("key_hash") @db.VarChar(128)
+ keyPrefix String @map("key_prefix") @db.VarChar(20)
+ lastUsedAt DateTime? @map("last_used_at") @db.Timestamptz(6)
+ createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([userId])
+ @@map("api_key")
+}
+
model Session {
id String @id() @map("session_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
diff --git a/public/intl/messages/en-US.json b/public/intl/messages/en-US.json
index 023f53b8c..34b26c98f 100644
--- a/public/intl/messages/en-US.json
+++ b/public/intl/messages/en-US.json
@@ -425,7 +425,13 @@
"two-factor-reset": "Reset 2FA",
"two-factor-global-active-tooltip": "Global 2FA is enabled",
"two-factor-verify": "Verify",
- "two-factor-login": "Two-factor authentication"
+ "two-factor-login": "Two-factor authentication",
+ "api-keys": "API keys",
+ "api-key": "API key",
+ "create-api-key": "Create API key",
+ "last-used": "Last used",
+ "never": "Never",
+ "copy": "Copy"
},
"message": {
"action-confirmation": "Type {confirmation} in the box below to confirm.",
@@ -510,6 +516,8 @@
"two-factor-error-no-pending-setup": "No pending two-factor authentication setup found.",
"two-factor-error-not-configured": "Two-factor authentication is not available because the server is missing a valid TWO_FACTOR_ENCRYPTION_KEY. Contact your administrator for help.",
"two-factor-locked-message": "Too many failed attempts. Try again at {time}.",
- "two-factor-active-description": "Your account is protected with two-factor authentication. Each login requires a code from your authenticator app."
+ "two-factor-active-description": "Your account is protected with two-factor authentication. Each login requires a code from your authenticator app.",
+ "api-key-created": "Copy your API key now. You won't be able to see it again.",
+ "no-api-keys": "You have not created any API keys."
}
}
diff --git a/public/openapi.json b/public/openapi.json
index 62cde911a..bb22993c1 100644
--- a/public/openapi.json
+++ b/public/openapi.json
@@ -670,7 +670,7 @@
"securitySchemes": {
"bearerAuth": {
"bearerFormat": "JWT",
- "description": "Token returned by POST /api/auth/login.",
+ "description": "Token returned by POST /api/auth/login, or an API key (`umami_…`) created under Settings → API keys (self-hosted only).",
"scheme": "bearer",
"type": "http"
},
@@ -7084,6 +7084,21 @@
"application/json": {
"schema": {
"properties": {
+ "apiKey": {
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ],
+ "type": "object"
+ },
"shareToken": {
"properties": {
"boardId": {
@@ -7218,6 +7233,301 @@
"x-umami-source": "src/app/api/me/contract.generated.ts"
}
},
+ "/api/me/api-keys": {
+ "get": {
+ "operationId": "getMeApiKeys",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "items": {
+ "properties": {
+ "createdAt": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "keyPrefix": {
+ "type": "string"
+ },
+ "lastUsedAt": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "createdAt",
+ "name",
+ "keyPrefix",
+ "lastUsedAt"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ }
+ }
+ },
+ "description": "The operation completed successfully."
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "example": {
+ "error": {
+ "code": "unauthorized",
+ "message": "Unauthorized.",
+ "status": 401
+ }
+ },
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ },
+ "description": "Unauthorized."
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "example": {
+ "error": {
+ "code": "not-found",
+ "message": "Not found.",
+ "status": 404
+ }
+ },
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ },
+ "description": "Not found."
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Get me api keys",
+ "tags": [
+ "Account"
+ ],
+ "x-umami-audience": "public",
+ "x-umami-contract": "explicit",
+ "x-umami-source": "src/app/api/me/api-keys/contract.generated.ts"
+ },
+ "post": {
+ "operationId": "postMeApiKeys",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "name": {
+ "maximum": 255,
+ "minimum": 1
+ }
+ },
+ "required": [
+ "name"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "createdAt": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "key": {
+ "type": "string"
+ },
+ "keyPrefix": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "key",
+ "id",
+ "createdAt",
+ "name",
+ "keyPrefix"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "The operation completed successfully."
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "example": {
+ "error": {
+ "code": "bad-request",
+ "message": "Bad request.",
+ "status": 400
+ }
+ },
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ },
+ "description": "Bad request."
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "example": {
+ "error": {
+ "code": "unauthorized",
+ "message": "Unauthorized.",
+ "status": 401
+ }
+ },
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ },
+ "description": "Unauthorized."
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "example": {
+ "error": {
+ "code": "not-found",
+ "message": "Not found.",
+ "status": 404
+ }
+ },
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ },
+ "description": "Not found."
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Create or update me api keys",
+ "tags": [
+ "Account"
+ ],
+ "x-umami-audience": "public",
+ "x-umami-contract": "explicit",
+ "x-umami-source": "src/app/api/me/api-keys/contract.generated.ts"
+ }
+ },
+ "/api/me/api-keys/{keyId}": {
+ "delete": {
+ "operationId": "deleteMeApiKeysKeyId",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "keyId",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "ok": {
+ "const": true
+ }
+ },
+ "required": [
+ "ok"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "The operation completed successfully."
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "example": {
+ "error": {
+ "code": "unauthorized",
+ "message": "Unauthorized.",
+ "status": 401
+ }
+ },
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ },
+ "description": "Unauthorized."
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "example": {
+ "error": {
+ "code": "not-found",
+ "message": "Not found.",
+ "status": 404
+ }
+ },
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ },
+ "description": "Not found."
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Delete me api keys key id",
+ "tags": [
+ "Account"
+ ],
+ "x-umami-audience": "public",
+ "x-umami-contract": "explicit",
+ "x-umami-source": "src/app/api/me/api-keys/[keyId]/contract.generated.ts"
+ }
+ },
"/api/me/password": {
"post": {
"operationId": "postMePassword",
@@ -16226,6 +16536,9 @@
"properties": {
"_count": {
"properties": {
+ "apiKeys": {
+ "type": "number"
+ },
"boards": {
"type": "number"
},
@@ -16271,10 +16584,51 @@
"twoFactorAuth",
"twoFactorBackupCodes",
"twoFactorOtpUseds",
- "twoFactorRateLimit"
+ "twoFactorRateLimit",
+ "apiKeys"
],
"type": "object"
},
+ "apiKeys": {
+ "items": {
+ "properties": {
+ "createdAt": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "keyHash": {
+ "type": "string"
+ },
+ "keyPrefix": {
+ "type": "string"
+ },
+ "lastUsedAt": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "userId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "userId",
+ "createdAt",
+ "name",
+ "keyHash",
+ "keyPrefix",
+ "lastUsedAt"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
"boards": {
"items": {
"properties": {
@@ -17007,6 +17361,7 @@
"boards",
"twoFactorBackupCodes",
"twoFactorOtpUseds",
+ "apiKeys",
"_count"
],
"type": "object"
diff --git a/src/app/(main)/settings/SettingsNav.tsx b/src/app/(main)/settings/SettingsNav.tsx
index 115f75bbf..fe40e33f4 100644
--- a/src/app/(main)/settings/SettingsNav.tsx
+++ b/src/app/(main)/settings/SettingsNav.tsx
@@ -2,7 +2,7 @@ import { Column, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen';
import { IconLabel } from '@/components/common/IconLabel';
import Link from '@/components/common/Link';
import { useMessages, useNavigation } from '@/components/hooks';
-import { ArrowLeft, Settings2, ShieldCheck, UserCircle, Users } from '@/components/icons';
+import { ArrowLeft, KeyRound, Settings2, ShieldCheck, UserCircle, Users } from '@/components/icons';
export function SettingsNav({
isCollapsed,
@@ -16,7 +16,7 @@ export function SettingsNav({
const items = [
{
- label: t(labels.application),
+ label: t(labels.settings),
items: [
{
id: 'preferences',
@@ -24,11 +24,6 @@ export function SettingsNav({
path: renderUrl('/settings/preferences'),
icon: ,
},
- ],
- },
- {
- label: t(labels.account),
- items: [
{
id: 'profile',
label: t(labels.profile),
@@ -47,6 +42,12 @@ export function SettingsNav({
path: renderUrl('/settings/security'),
icon: ,
},
+ {
+ id: 'api-keys',
+ label: t(labels.apiKeys),
+ path: renderUrl('/settings/api-keys'),
+ icon: ,
+ },
],
},
];
diff --git a/src/app/(main)/settings/api-keys/ApiKeyAddButton.tsx b/src/app/(main)/settings/api-keys/ApiKeyAddButton.tsx
new file mode 100644
index 000000000..163b2d027
--- /dev/null
+++ b/src/app/(main)/settings/api-keys/ApiKeyAddButton.tsx
@@ -0,0 +1,30 @@
+import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen';
+import { useMessages, useModified } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+import { ApiKeyAddForm } from './ApiKeyAddForm';
+
+export function ApiKeyAddButton({ onSave }: { onSave?: () => void }) {
+ const { t, labels } = useMessages();
+ const { touch } = useModified();
+
+ const handleSave = () => {
+ touch('api-keys');
+ onSave?.();
+ };
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(main)/settings/api-keys/ApiKeyAddForm.tsx b/src/app/(main)/settings/api-keys/ApiKeyAddForm.tsx
new file mode 100644
index 000000000..6ff03a7c2
--- /dev/null
+++ b/src/app/(main)/settings/api-keys/ApiKeyAddForm.tsx
@@ -0,0 +1,70 @@
+import {
+ Alert,
+ AlertTitle,
+ Button,
+ Column,
+ Form,
+ FormButtons,
+ FormField,
+ FormSubmitButton,
+ Icon,
+ Row,
+ TextField,
+} from '@umami/react-zen';
+import { useState } from 'react';
+import { CopyButton } from '@/components/common/CopyButton';
+import { useMessages, useUpdateQuery } from '@/components/hooks';
+import { AlertTriangle } from '@/components/icons';
+
+export function ApiKeyAddForm({ onSave, onClose }: { onSave: () => void; onClose: () => void }) {
+ const { t, labels, messages, getErrorMessage } = useMessages();
+ const { mutateAsync, error, isPending } = useUpdateQuery('/me/api-keys');
+ const [createdKey, setCreatedKey] = useState(null);
+
+ const handleSubmit = async (data: any) => {
+ await mutateAsync(data, {
+ onSuccess: async result => {
+ setCreatedKey(result.key);
+ onSave?.();
+ },
+ });
+ };
+
+ if (createdKey) {
+ return (
+
+
+
+
+
+ {t(messages.apiKeyCreated)}
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/app/(main)/settings/api-keys/ApiKeyDeleteButton.tsx b/src/app/(main)/settings/api-keys/ApiKeyDeleteButton.tsx
new file mode 100644
index 000000000..205c1477e
--- /dev/null
+++ b/src/app/(main)/settings/api-keys/ApiKeyDeleteButton.tsx
@@ -0,0 +1,47 @@
+import { ConfirmationForm } from '@/components/common/ConfirmationForm';
+import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
+import { Trash } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+
+export function ApiKeyDeleteButton({
+ keyId,
+ name,
+ onSave,
+}: {
+ keyId: string;
+ name: string;
+ onSave?: () => void;
+}) {
+ const { t, labels, messages, getErrorMessage } = useMessages();
+ const { mutateAsync, isPending, error } = useDeleteQuery(`/me/api-keys/${keyId}`);
+ const { touch } = useModified();
+
+ const handleConfirm = async (close: () => void) => {
+ await mutateAsync(null, {
+ onSuccess: () => {
+ touch('api-keys');
+ onSave?.();
+ close();
+ },
+ });
+ };
+
+ return (
+ } title={t(labels.confirm)} variant="quiet" width="400px">
+ {({ close }) => (
+ {chunks},
+ })}
+ isLoading={isPending}
+ error={getErrorMessage(error)}
+ onConfirm={handleConfirm.bind(null, close)}
+ onClose={close}
+ buttonLabel={t(labels.delete)}
+ buttonVariant="danger"
+ />
+ )}
+
+ );
+}
diff --git a/src/app/(main)/settings/api-keys/ApiKeysPage.tsx b/src/app/(main)/settings/api-keys/ApiKeysPage.tsx
new file mode 100644
index 000000000..ead200164
--- /dev/null
+++ b/src/app/(main)/settings/api-keys/ApiKeysPage.tsx
@@ -0,0 +1,30 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { PageBody } from '@/components/common/PageBody';
+import { PageHeader } from '@/components/common/PageHeader';
+import { Panel } from '@/components/common/Panel';
+import { useConfig, useMessages } from '@/components/hooks';
+import { ApiKeyAddButton } from './ApiKeyAddButton';
+import { ApiKeysTable } from './ApiKeysTable';
+
+export function ApiKeysPage() {
+ const { t, labels } = useMessages();
+ const { cloudMode } = useConfig();
+
+ if (cloudMode) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(main)/settings/api-keys/ApiKeysTable.tsx b/src/app/(main)/settings/api-keys/ApiKeysTable.tsx
new file mode 100644
index 000000000..5c95dd461
--- /dev/null
+++ b/src/app/(main)/settings/api-keys/ApiKeysTable.tsx
@@ -0,0 +1,46 @@
+import { DataColumn, DataTable, Text } from '@umami/react-zen';
+import { DateDistance } from '@/components/common/DateDistance';
+import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { type ApiKeyData, useApiKeysQuery, useMessages } from '@/components/hooks';
+import { KeyRound } from '@/components/icons';
+import { ApiKeyDeleteButton } from './ApiKeyDeleteButton';
+
+export function ApiKeysTable() {
+ const { t, labels, messages } = useMessages();
+ const { data, isLoading, error } = useApiKeysQuery();
+
+ return (
+ (
+ } description={t(messages.noApiKeys)} />
+ )}
+ >
+
+
+
+ {(row: ApiKeyData) => {row.keyPrefix}…}
+
+
+ {(row: ApiKeyData) => }
+
+
+ {(row: ApiKeyData) =>
+ row.lastUsedAt ? (
+
+ ) : (
+ {t(labels.never)}
+ )
+ }
+
+
+ {(row: ApiKeyData) => }
+
+
+
+ );
+}
diff --git a/src/app/(main)/settings/api-keys/page.tsx b/src/app/(main)/settings/api-keys/page.tsx
new file mode 100644
index 000000000..9d664662b
--- /dev/null
+++ b/src/app/(main)/settings/api-keys/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from 'next';
+import { ApiKeysPage } from './ApiKeysPage';
+
+export default function () {
+ return ;
+}
+
+export const metadata: Metadata = {
+ title: 'API keys',
+};
diff --git a/src/app/api/me/api-keys/[keyId]/contract.generated.ts b/src/app/api/me/api-keys/[keyId]/contract.generated.ts
new file mode 100644
index 000000000..4596c9378
--- /dev/null
+++ b/src/app/api/me/api-keys/[keyId]/contract.generated.ts
@@ -0,0 +1,80 @@
+// Generated by `pnpm openapi:contracts`. Do not edit directly.
+
+import type { ZodOpenApiOperationObject } from 'zod-openapi';
+import { defineOperation } from '@/openapi/operation';
+
+const operation1 = defineOperation({
+ method: 'delete',
+ path: '/api/me/api-keys/{keyId}',
+ audience: 'public',
+ auth: 'bearer',
+ operation: {
+ operationId: 'deleteMeApiKeysKeyId',
+ summary: 'Delete me api keys key id',
+ tags: ['Account'],
+ parameters: [
+ {
+ name: 'keyId',
+ in: 'path',
+ required: true,
+ schema: {
+ type: 'string',
+ },
+ },
+ ],
+ responses: {
+ '200': {
+ description: 'The operation completed successfully.',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: {
+ ok: {
+ const: true,
+ },
+ },
+ required: ['ok'],
+ },
+ },
+ },
+ },
+ '401': {
+ description: 'Unauthorized.',
+ content: {
+ 'application/json': {
+ example: {
+ error: {
+ message: 'Unauthorized.',
+ code: 'unauthorized',
+ status: 401,
+ },
+ },
+ schema: {
+ $ref: '#/components/schemas/ApiError',
+ },
+ },
+ },
+ },
+ '404': {
+ description: 'Not found.',
+ content: {
+ 'application/json': {
+ example: {
+ error: {
+ message: 'Not found.',
+ code: 'not-found',
+ status: 404,
+ },
+ },
+ schema: {
+ $ref: '#/components/schemas/ApiError',
+ },
+ },
+ },
+ },
+ },
+ } as ZodOpenApiOperationObject,
+});
+
+export const operations = [operation1] as const;
diff --git a/src/app/api/me/api-keys/[keyId]/route.test.ts b/src/app/api/me/api-keys/[keyId]/route.test.ts
new file mode 100644
index 000000000..1ad4b4e04
--- /dev/null
+++ b/src/app/api/me/api-keys/[keyId]/route.test.ts
@@ -0,0 +1,61 @@
+import { beforeEach, expect, test, vi } from 'vitest';
+import { parseRequest } from '@/lib/request';
+import { DELETE } from './route';
+
+const mocks = vi.hoisted(() => ({
+ deleteApiKey: vi.fn(),
+}));
+
+vi.mock('@/lib/request', () => ({
+ parseRequest: vi.fn(),
+}));
+
+vi.mock('@/queries/prisma/apiKey', () => ({
+ deleteApiKey: mocks.deleteApiKey,
+}));
+
+const parseRequestMock = vi.mocked(parseRequest);
+
+function deleteRequest(keyId = 'key-1') {
+ return DELETE(new Request(`http://localhost/api/me/api-keys/${keyId}`, { method: 'DELETE' }), {
+ params: Promise.resolve({ keyId }),
+ });
+}
+
+beforeEach(() => {
+ vi.unstubAllEnvs();
+ vi.stubEnv('CLOUD_MODE', '');
+ parseRequestMock.mockReset();
+ mocks.deleteApiKey.mockReset();
+
+ parseRequestMock.mockResolvedValue({
+ auth: { user: { id: 'user-1' } },
+ error: undefined,
+ });
+});
+
+test('DELETE removes a key owned by the current user', async () => {
+ mocks.deleteApiKey.mockResolvedValue(1);
+
+ const response = await deleteRequest();
+
+ expect(response.status).toBe(200);
+ expect(mocks.deleteApiKey).toHaveBeenCalledWith('key-1', 'user-1');
+});
+
+test('DELETE returns 404 when the key does not belong to the user', async () => {
+ mocks.deleteApiKey.mockResolvedValue(0);
+
+ const response = await deleteRequest('someone-elses-key');
+
+ expect(response.status).toBe(404);
+});
+
+test('DELETE returns 404 in cloud mode', async () => {
+ vi.stubEnv('CLOUD_MODE', '1');
+
+ const response = await deleteRequest();
+
+ expect(response.status).toBe(404);
+ expect(mocks.deleteApiKey).not.toHaveBeenCalled();
+});
diff --git a/src/app/api/me/api-keys/[keyId]/route.ts b/src/app/api/me/api-keys/[keyId]/route.ts
new file mode 100644
index 000000000..161808ce1
--- /dev/null
+++ b/src/app/api/me/api-keys/[keyId]/route.ts
@@ -0,0 +1,26 @@
+import { isApiKeyEnabled } from '@/lib/api-key';
+import { parseRequest } from '@/lib/request';
+import { notFound, ok } from '@/lib/response';
+import { deleteApiKey } from '@/queries/prisma/apiKey';
+
+export async function DELETE(request: Request, { params }: { params: Promise<{ keyId: string }> }) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ if (!isApiKeyEnabled()) {
+ return notFound();
+ }
+
+ const { keyId } = await params;
+
+ const count = await deleteApiKey(keyId, auth.user.id);
+
+ if (!count) {
+ return notFound();
+ }
+
+ return ok();
+}
diff --git a/src/app/api/me/api-keys/contract.generated.ts b/src/app/api/me/api-keys/contract.generated.ts
new file mode 100644
index 000000000..953e17401
--- /dev/null
+++ b/src/app/api/me/api-keys/contract.generated.ts
@@ -0,0 +1,198 @@
+// Generated by `pnpm openapi:contracts`. Do not edit directly.
+
+import type { ZodOpenApiOperationObject } from 'zod-openapi';
+import { defineOperation } from '@/openapi/operation';
+
+const operation1 = defineOperation({
+ method: 'get',
+ path: '/api/me/api-keys',
+ audience: 'public',
+ auth: 'bearer',
+ operation: {
+ operationId: 'getMeApiKeys',
+ summary: 'Get me api keys',
+ tags: ['Account'],
+ responses: {
+ '200': {
+ description: 'The operation completed successfully.',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ },
+ createdAt: {
+ type: 'string',
+ format: 'date-time',
+ },
+ name: {
+ type: 'string',
+ },
+ keyPrefix: {
+ type: 'string',
+ },
+ lastUsedAt: {
+ type: 'string',
+ format: 'date-time',
+ },
+ },
+ required: ['id', 'createdAt', 'name', 'keyPrefix', 'lastUsedAt'],
+ },
+ },
+ },
+ },
+ },
+ '401': {
+ description: 'Unauthorized.',
+ content: {
+ 'application/json': {
+ example: {
+ error: {
+ message: 'Unauthorized.',
+ code: 'unauthorized',
+ status: 401,
+ },
+ },
+ schema: {
+ $ref: '#/components/schemas/ApiError',
+ },
+ },
+ },
+ },
+ '404': {
+ description: 'Not found.',
+ content: {
+ 'application/json': {
+ example: {
+ error: {
+ message: 'Not found.',
+ code: 'not-found',
+ status: 404,
+ },
+ },
+ schema: {
+ $ref: '#/components/schemas/ApiError',
+ },
+ },
+ },
+ },
+ },
+ } as ZodOpenApiOperationObject,
+});
+
+const operation2 = defineOperation({
+ method: 'post',
+ path: '/api/me/api-keys',
+ audience: 'public',
+ auth: 'bearer',
+ operation: {
+ operationId: 'postMeApiKeys',
+ summary: 'Create or update me api keys',
+ tags: ['Account'],
+ requestBody: {
+ required: true,
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: {
+ name: {
+ minimum: 1,
+ maximum: 255,
+ },
+ },
+ required: ['name'],
+ },
+ },
+ },
+ },
+ responses: {
+ '200': {
+ description: 'The operation completed successfully.',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: {
+ key: {
+ type: 'string',
+ },
+ id: {
+ type: 'string',
+ },
+ createdAt: {
+ type: 'string',
+ format: 'date-time',
+ },
+ name: {
+ type: 'string',
+ },
+ keyPrefix: {
+ type: 'string',
+ },
+ },
+ required: ['key', 'id', 'createdAt', 'name', 'keyPrefix'],
+ },
+ },
+ },
+ },
+ '400': {
+ description: 'Bad request.',
+ content: {
+ 'application/json': {
+ example: {
+ error: {
+ message: 'Bad request.',
+ code: 'bad-request',
+ status: 400,
+ },
+ },
+ schema: {
+ $ref: '#/components/schemas/ApiError',
+ },
+ },
+ },
+ },
+ '401': {
+ description: 'Unauthorized.',
+ content: {
+ 'application/json': {
+ example: {
+ error: {
+ message: 'Unauthorized.',
+ code: 'unauthorized',
+ status: 401,
+ },
+ },
+ schema: {
+ $ref: '#/components/schemas/ApiError',
+ },
+ },
+ },
+ },
+ '404': {
+ description: 'Not found.',
+ content: {
+ 'application/json': {
+ example: {
+ error: {
+ message: 'Not found.',
+ code: 'not-found',
+ status: 404,
+ },
+ },
+ schema: {
+ $ref: '#/components/schemas/ApiError',
+ },
+ },
+ },
+ },
+ },
+ } as ZodOpenApiOperationObject,
+});
+
+export const operations = [operation1, operation2] as const;
diff --git a/src/app/api/me/api-keys/route.test.ts b/src/app/api/me/api-keys/route.test.ts
new file mode 100644
index 000000000..5371073a7
--- /dev/null
+++ b/src/app/api/me/api-keys/route.test.ts
@@ -0,0 +1,100 @@
+import { beforeEach, expect, test, vi } from 'vitest';
+import { parseRequest } from '@/lib/request';
+import { GET, POST } from './route';
+
+const mocks = vi.hoisted(() => ({
+ createApiKey: vi.fn(),
+ getUserApiKeys: vi.fn(),
+}));
+
+vi.mock('@/lib/request', () => ({
+ parseRequest: vi.fn(),
+}));
+
+vi.mock('@/queries/prisma/apiKey', () => ({
+ createApiKey: mocks.createApiKey,
+ getUserApiKeys: mocks.getUserApiKeys,
+}));
+
+const parseRequestMock = vi.mocked(parseRequest);
+
+beforeEach(() => {
+ vi.unstubAllEnvs();
+ vi.stubEnv('CLOUD_MODE', '');
+ parseRequestMock.mockReset();
+ mocks.createApiKey.mockReset();
+ mocks.getUserApiKeys.mockReset();
+
+ parseRequestMock.mockResolvedValue({
+ auth: { user: { id: 'user-1' } },
+ body: { name: 'CI pipeline' },
+ error: undefined,
+ });
+});
+
+test('GET returns the current user api keys', async () => {
+ const keys = [{ id: 'key-1', name: 'CI pipeline', keyPrefix: 'umami_abcdefgh' }];
+ mocks.getUserApiKeys.mockResolvedValue(keys as any);
+
+ const response = await GET(new Request('http://localhost/api/me/api-keys'));
+
+ expect(response.status).toBe(200);
+ await expect(response.json()).resolves.toEqual(keys);
+ expect(mocks.getUserApiKeys).toHaveBeenCalledWith('user-1');
+});
+
+test('GET returns 404 in cloud mode', async () => {
+ vi.stubEnv('CLOUD_MODE', '1');
+
+ const response = await GET(new Request('http://localhost/api/me/api-keys'));
+
+ expect(response.status).toBe(404);
+ expect(mocks.getUserApiKeys).not.toHaveBeenCalled();
+});
+
+test('GET returns the auth error when unauthenticated', async () => {
+ parseRequestMock.mockResolvedValue({
+ auth: null,
+ error: () => new Response(null, { status: 401 }),
+ });
+
+ const response = await GET(new Request('http://localhost/api/me/api-keys'));
+
+ expect(response.status).toBe(401);
+});
+
+test('POST creates a key, stores only the hash, and returns the plaintext once', async () => {
+ mocks.createApiKey.mockImplementation(async data => ({
+ id: data.id,
+ name: data.name,
+ keyPrefix: data.keyPrefix,
+ createdAt: new Date('2026-01-01T00:00:00Z'),
+ }));
+
+ const response = await POST(new Request('http://localhost/api/me/api-keys', { method: 'POST' }));
+
+ expect(response.status).toBe(200);
+
+ const body = await response.json();
+
+ expect(body.key).toMatch(/^umami_[0-9a-zA-Z]{32}$/);
+ expect(body.name).toBe('CI pipeline');
+ expect(body.keyPrefix).toBe(body.key.slice(0, 14));
+ expect(body).not.toHaveProperty('keyHash');
+
+ const stored = mocks.createApiKey.mock.calls[0][0];
+
+ expect(stored.userId).toBe('user-1');
+ expect(stored.keyHash).not.toBe(body.key);
+ expect(stored.keyHash).toHaveLength(128);
+ expect(stored).not.toHaveProperty('key');
+});
+
+test('POST returns 404 in cloud mode', async () => {
+ vi.stubEnv('CLOUD_MODE', '1');
+
+ const response = await POST(new Request('http://localhost/api/me/api-keys', { method: 'POST' }));
+
+ expect(response.status).toBe(404);
+ expect(mocks.createApiKey).not.toHaveBeenCalled();
+});
diff --git a/src/app/api/me/api-keys/route.ts b/src/app/api/me/api-keys/route.ts
new file mode 100644
index 000000000..b08afc5e6
--- /dev/null
+++ b/src/app/api/me/api-keys/route.ts
@@ -0,0 +1,49 @@
+import { z } from 'zod';
+import { generateApiKey, getApiKeyPrefix, hashApiKey, isApiKeyEnabled } from '@/lib/api-key';
+import { uuid } from '@/lib/crypto';
+import { parseRequest } from '@/lib/request';
+import { json, notFound } from '@/lib/response';
+import { createApiKey, getUserApiKeys } from '@/queries/prisma/apiKey';
+
+export async function GET(request: Request) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ if (!isApiKeyEnabled()) {
+ return notFound();
+ }
+
+ return json(await getUserApiKeys(auth.user.id));
+}
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ name: z.string().trim().min(1).max(255),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ if (!isApiKeyEnabled()) {
+ return notFound();
+ }
+
+ const key = generateApiKey();
+
+ const apiKey = await createApiKey({
+ id: uuid(),
+ userId: auth.user.id,
+ name: body.name,
+ keyHash: hashApiKey(key),
+ keyPrefix: getApiKeyPrefix(key),
+ });
+
+ // The plaintext key is only returned once, at creation time.
+ return json({ ...apiKey, key });
+}
diff --git a/src/app/api/me/contract.generated.ts b/src/app/api/me/contract.generated.ts
index 48af82b18..18a395cd4 100644
--- a/src/app/api/me/contract.generated.ts
+++ b/src/app/api/me/contract.generated.ts
@@ -38,6 +38,18 @@ const operation1 = defineOperation({
},
required: ['id', 'username', 'role', 'isAdmin'],
},
+ apiKey: {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ },
+ name: {
+ type: 'string',
+ },
+ },
+ required: ['id', 'name'],
+ },
shareToken: {
type: 'object',
properties: {
diff --git a/src/app/api/users/[userId]/contract.generated.ts b/src/app/api/users/[userId]/contract.generated.ts
index 8a8eb56e8..fdf0b916e 100644
--- a/src/app/api/users/[userId]/contract.generated.ts
+++ b/src/app/api/users/[userId]/contract.generated.ts
@@ -770,6 +770,46 @@ const operation2 = defineOperation({
required: ['id', 'userId', 'otp', 'expiresAt'],
},
},
+ apiKeys: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ },
+ userId: {
+ type: 'string',
+ },
+ createdAt: {
+ type: 'string',
+ format: 'date-time',
+ },
+ name: {
+ type: 'string',
+ },
+ keyHash: {
+ type: 'string',
+ },
+ keyPrefix: {
+ type: 'string',
+ },
+ lastUsedAt: {
+ type: 'string',
+ format: 'date-time',
+ },
+ },
+ required: [
+ 'id',
+ 'userId',
+ 'createdAt',
+ 'name',
+ 'keyHash',
+ 'keyPrefix',
+ 'lastUsedAt',
+ ],
+ },
+ },
_count: {
type: 'object',
properties: {
@@ -806,6 +846,9 @@ const operation2 = defineOperation({
twoFactorRateLimit: {
type: 'number',
},
+ apiKeys: {
+ type: 'number',
+ },
},
required: [
'websites',
@@ -819,6 +862,7 @@ const operation2 = defineOperation({
'twoFactorBackupCodes',
'twoFactorOtpUseds',
'twoFactorRateLimit',
+ 'apiKeys',
],
},
},
@@ -840,6 +884,7 @@ const operation2 = defineOperation({
'boards',
'twoFactorBackupCodes',
'twoFactorOtpUseds',
+ 'apiKeys',
'_count',
],
},
diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts
index b4a4db3c5..cc6e70571 100644
--- a/src/components/hooks/index.ts
+++ b/src/components/hooks/index.ts
@@ -11,6 +11,7 @@ export * from './context/useWebsite';
// Query hooks
export * from './queries/useActiveUsersQuery';
+export * from './queries/useApiKeysQuery';
export * from './queries/useBoardQuery';
export * from './queries/useBoardSharesQuery';
export * from './queries/useBoardsQuery';
diff --git a/src/components/hooks/queries/useApiKeysQuery.ts b/src/components/hooks/queries/useApiKeysQuery.ts
new file mode 100644
index 000000000..f921b46e5
--- /dev/null
+++ b/src/components/hooks/queries/useApiKeysQuery.ts
@@ -0,0 +1,20 @@
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export interface ApiKeyData {
+ id: string;
+ name: string;
+ keyPrefix: string;
+ lastUsedAt: string | null;
+ createdAt: string;
+}
+
+export function useApiKeysQuery() {
+ const { get, useQuery } = useApi();
+ const { modified } = useModified('api-keys');
+
+ return useQuery({
+ queryKey: ['api-keys', { modified }],
+ queryFn: () => get('/me/api-keys'),
+ });
+}
diff --git a/src/components/messages.ts b/src/components/messages.ts
index 35e2f7bcb..e14f3aeed 100644
--- a/src/components/messages.ts
+++ b/src/components/messages.ts
@@ -430,6 +430,12 @@ export const labels: Record = {
twoFactorGlobalActiveTooltip: 'label.two-factor-global-active-tooltip',
twoFactorVerify: 'label.two-factor-verify',
twoFactorLogin: 'label.two-factor-login',
+ apiKeys: 'label.api-keys',
+ apiKey: 'label.api-key',
+ createApiKey: 'label.create-api-key',
+ lastUsed: 'label.last-used',
+ never: 'label.never',
+ copy: 'label.copy',
};
export const messages: Record = {
@@ -511,4 +517,6 @@ export const messages: Record = {
twoFactorErrorNoPendingSetup: 'message.two-factor-error-no-pending-setup',
twoFactorErrorNotConfigured: 'message.two-factor-error-not-configured',
replayMinDurationDescription: 'message.replay-min-duration-description',
+ apiKeyCreated: 'message.api-key-created',
+ noApiKeys: 'message.no-api-keys',
};
diff --git a/src/components/modals/TwoFactorSetupModal.tsx b/src/components/modals/TwoFactorSetupModal.tsx
index 4277b18f2..718c7d6f8 100644
--- a/src/components/modals/TwoFactorSetupModal.tsx
+++ b/src/components/modals/TwoFactorSetupModal.tsx
@@ -2,7 +2,6 @@
import { useQueryClient } from '@tanstack/react-query';
import {
Alert,
- AlertDescription,
AlertTitle,
Box,
Button,
@@ -193,8 +192,7 @@ export function TwoFactorSetupModal({ required, onClose }: TwoFactorSetupModalPr
{error && (
- {t(messages.error)}
- {error}
+ {error}
)}
diff --git a/src/lib/api-key.test.ts b/src/lib/api-key.test.ts
new file mode 100644
index 000000000..3033076db
--- /dev/null
+++ b/src/lib/api-key.test.ts
@@ -0,0 +1,89 @@
+import { afterEach, describe, expect, test, vi } from 'vitest';
+import {
+ API_KEY_PREFIX,
+ generateApiKey,
+ getApiKeyPrefix,
+ hashApiKey,
+ isApiKey,
+ isApiKeyBlockedPath,
+ isApiKeyEnabled,
+} from './api-key';
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+});
+
+describe('generateApiKey', () => {
+ test('produces umami_ followed by 32 base62 characters', () => {
+ const key = generateApiKey();
+
+ expect(key).toMatch(/^umami_[0-9a-zA-Z]{32}$/);
+ });
+
+ test('produces unique keys', () => {
+ expect(generateApiKey()).not.toBe(generateApiKey());
+ });
+});
+
+describe('hashApiKey', () => {
+ test('is deterministic and does not equal the key', () => {
+ const key = generateApiKey();
+
+ expect(hashApiKey(key)).toBe(hashApiKey(key));
+ expect(hashApiKey(key)).not.toBe(key);
+ expect(hashApiKey(key)).toHaveLength(128);
+ });
+});
+
+describe('getApiKeyPrefix', () => {
+ test('returns the prefix plus the first 8 characters', () => {
+ const key = `${API_KEY_PREFIX}abcdefghijklmnopqrstuvwxyz012345`;
+
+ expect(getApiKeyPrefix(key)).toBe('umami_abcdefgh');
+ });
+});
+
+describe('isApiKey', () => {
+ test('detects api keys by prefix', () => {
+ expect(isApiKey('umami_abc')).toBe(true);
+ expect(isApiKey('eyJhbGciOi')).toBe(false);
+ expect(isApiKey(undefined)).toBe(false);
+ expect(isApiKey(null)).toBe(false);
+ });
+});
+
+describe('isApiKeyBlockedPath', () => {
+ test('blocks credential and admin routes', () => {
+ expect(isApiKeyBlockedPath('/api/me/password')).toBe(true);
+ expect(isApiKeyBlockedPath('/api/me/api-keys')).toBe(true);
+ expect(isApiKeyBlockedPath('/api/me/api-keys/abc')).toBe(true);
+ expect(isApiKeyBlockedPath('/api/2fa/status')).toBe(true);
+ expect(isApiKeyBlockedPath('/api/auth/logout')).toBe(true);
+ expect(isApiKeyBlockedPath('/api/users')).toBe(true);
+ expect(isApiKeyBlockedPath('/api/users/abc/websites')).toBe(true);
+ expect(isApiKeyBlockedPath('/api/admin/users')).toBe(true);
+ });
+
+ test('allows data routes', () => {
+ expect(isApiKeyBlockedPath('/api/me')).toBe(false);
+ expect(isApiKeyBlockedPath('/api/me/websites')).toBe(false);
+ expect(isApiKeyBlockedPath('/api/me/teams')).toBe(false);
+ expect(isApiKeyBlockedPath('/api/websites')).toBe(false);
+ expect(isApiKeyBlockedPath('/api/websites/abc/stats')).toBe(false);
+ expect(isApiKeyBlockedPath('/api/authors')).toBe(false);
+ });
+});
+
+describe('isApiKeyEnabled', () => {
+ test('is disabled in cloud mode', () => {
+ vi.stubEnv('CLOUD_MODE', '1');
+
+ expect(isApiKeyEnabled()).toBe(false);
+ });
+
+ test('is enabled when CLOUD_MODE is unset', () => {
+ vi.stubEnv('CLOUD_MODE', '');
+
+ expect(isApiKeyEnabled()).toBe(true);
+ });
+});
diff --git a/src/lib/api-key.ts b/src/lib/api-key.ts
new file mode 100644
index 000000000..0840a6b69
--- /dev/null
+++ b/src/lib/api-key.ts
@@ -0,0 +1,42 @@
+import { hash } from '@/lib/crypto';
+import { getRandomChars } from '@/lib/generate';
+
+export const API_KEY_PREFIX = 'umami_';
+export const API_KEY_LENGTH = 32;
+export const API_KEY_DISPLAY_LENGTH = API_KEY_PREFIX.length + 8;
+export const API_KEY_LAST_USED_INTERVAL = 5 * 60 * 1000;
+
+// Routes that manage account credentials or admin state must not be
+// reachable with an API key. Matched as path prefixes.
+export const API_KEY_BLOCKED_PATHS = [
+ '/api/me/password',
+ '/api/me/api-keys',
+ '/api/2fa',
+ '/api/auth',
+ '/api/users',
+ '/api/admin',
+];
+
+export function generateApiKey() {
+ return `${API_KEY_PREFIX}${getRandomChars(API_KEY_LENGTH)}`;
+}
+
+export function hashApiKey(key: string) {
+ return hash(key);
+}
+
+export function getApiKeyPrefix(key: string) {
+ return key.slice(0, API_KEY_DISPLAY_LENGTH);
+}
+
+export function isApiKey(token?: string | null): token is string {
+ return typeof token === 'string' && token.startsWith(API_KEY_PREFIX);
+}
+
+export function isApiKeyBlockedPath(pathname: string) {
+ return API_KEY_BLOCKED_PATHS.some(path => pathname === path || pathname.startsWith(`${path}/`));
+}
+
+export function isApiKeyEnabled() {
+ return !process.env.CLOUD_MODE;
+}
diff --git a/src/lib/auth.test.ts b/src/lib/auth.test.ts
index fed2dad29..9db0b8567 100644
--- a/src/lib/auth.test.ts
+++ b/src/lib/auth.test.ts
@@ -2,7 +2,9 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { hash } from '@/lib/crypto';
import { parseSecureToken } from '@/lib/jwt';
import redis from '@/lib/redis';
+import { getApiKeyByHash, updateApiKeyLastUsed } from '@/queries/prisma/apiKey';
import { getUser } from '@/queries/prisma/user';
+import { hashApiKey } from './api-key';
import { checkAuth } from './auth';
vi.mock('@/lib/jwt', () => ({
@@ -14,6 +16,11 @@ vi.mock('@/queries/prisma/user', () => ({
getUser: vi.fn(),
}));
+vi.mock('@/queries/prisma/apiKey', () => ({
+ getApiKeyByHash: vi.fn(),
+ updateApiKeyLastUsed: vi.fn(() => Promise.resolve()),
+}));
+
vi.mock('@/lib/redis', () => ({
default: {
enabled: false,
@@ -25,6 +32,8 @@ vi.mock('@/lib/redis', () => ({
const parseSecureTokenMock = vi.mocked(parseSecureToken);
const getUserMock = vi.mocked(getUser);
+const getApiKeyByHashMock = vi.mocked(getApiKeyByHash);
+const updateApiKeyLastUsedMock = vi.mocked(updateApiKeyLastUsed);
const redisMock = redis as unknown as {
enabled: boolean;
client: {
@@ -50,12 +59,129 @@ function mockUser() {
}
beforeEach(() => {
+ vi.unstubAllEnvs();
+ vi.stubEnv('CLOUD_MODE', '');
parseSecureTokenMock.mockReset();
getUserMock.mockReset();
+ getApiKeyByHashMock.mockReset();
+ updateApiKeyLastUsedMock.mockClear();
redisMock.enabled = false;
redisMock.client.get.mockReset();
});
+describe('checkAuth api keys', () => {
+ const API_KEY = 'umami_abcdefghijklmnopqrstuvwxyz012345';
+
+ function apiKeyRequest(path = '/api/websites') {
+ return new Request(`http://localhost${path}`, {
+ headers: { authorization: `Bearer ${API_KEY}` },
+ });
+ }
+
+ function mockApiKey(lastUsedAt: Date | null = null) {
+ getApiKeyByHashMock.mockResolvedValue({
+ id: 'key-1',
+ userId: 'user-1',
+ name: 'CI',
+ keyHash: hashApiKey(API_KEY),
+ keyPrefix: 'umami_abcdefgh',
+ lastUsedAt,
+ createdAt: new Date(),
+ } as any);
+ }
+
+ test('authorizes a valid api key and looks it up by hash', async () => {
+ mockApiKey();
+ mockUser();
+
+ const result: any = await checkAuth(apiKeyRequest());
+
+ expect(getApiKeyByHashMock).toHaveBeenCalledWith(hashApiKey(API_KEY));
+ expect(result?.user?.id).toBe('user-1');
+ expect(result?.user).not.toHaveProperty('password');
+ expect(result?.apiKey).toEqual({ id: 'key-1', name: 'CI' });
+ expect(parseSecureTokenMock).not.toHaveBeenCalled();
+ });
+
+ test('rejects an unknown api key', async () => {
+ getApiKeyByHashMock.mockResolvedValue(null);
+
+ const result = await checkAuth(apiKeyRequest());
+
+ expect(result).toBeNull();
+ expect(getUserMock).not.toHaveBeenCalled();
+ });
+
+ test('rejects an api key whose user no longer exists', async () => {
+ mockApiKey();
+ getUserMock.mockResolvedValue(null as any);
+
+ const result = await checkAuth(apiKeyRequest());
+
+ expect(result).toBeNull();
+ });
+
+ test('rejects api keys on sensitive routes', async () => {
+ mockApiKey();
+ mockUser();
+
+ for (const path of [
+ '/api/me/password',
+ '/api/me/api-keys',
+ '/api/me/api-keys/key-1',
+ '/api/2fa/status',
+ '/api/auth/logout',
+ '/api/users',
+ '/api/admin/users',
+ ]) {
+ expect(await checkAuth(apiKeyRequest(path))).toBeNull();
+ }
+
+ expect(getApiKeyByHashMock).not.toHaveBeenCalled();
+ });
+
+ test('ignores api keys in cloud mode', async () => {
+ vi.stubEnv('CLOUD_MODE', '1');
+ mockApiKey();
+ mockUser();
+ parseSecureTokenMock.mockReturnValue(null);
+
+ const result = await checkAuth(apiKeyRequest());
+
+ expect(result).toBeNull();
+ expect(getApiKeyByHashMock).not.toHaveBeenCalled();
+ expect(parseSecureTokenMock).toHaveBeenCalled();
+ });
+
+ test('updates lastUsedAt when stale', async () => {
+ mockApiKey(new Date(Date.now() - 60 * 60 * 1000));
+ mockUser();
+
+ await checkAuth(apiKeyRequest());
+
+ expect(updateApiKeyLastUsedMock).toHaveBeenCalledWith('key-1');
+ });
+
+ test('does not update lastUsedAt when recently used', async () => {
+ mockApiKey(new Date());
+ mockUser();
+
+ await checkAuth(apiKeyRequest());
+
+ expect(updateApiKeyLastUsedMock).not.toHaveBeenCalled();
+ });
+
+ test('does not treat a jwt as an api key', async () => {
+ parseSecureTokenMock.mockReturnValue({ userId: 'user-1' } as any);
+ mockUser();
+
+ const result = await checkAuth(authedRequest());
+
+ expect(result?.user?.id).toBe('user-1');
+ expect(getApiKeyByHashMock).not.toHaveBeenCalled();
+ });
+});
+
describe('checkAuth password fingerprint', () => {
test('authorizes a stateless token whose fingerprint matches the current password', async () => {
parseSecureTokenMock.mockReturnValue({ userId: 'user-1', pwd: hash(PASSWORD_HASH) } as any);
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 559c7f90e..1c3bba6c9 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -1,4 +1,11 @@
import debug from 'debug';
+import {
+ API_KEY_LAST_USED_INTERVAL,
+ hashApiKey,
+ isApiKey,
+ isApiKeyBlockedPath,
+ isApiKeyEnabled,
+} from '@/lib/api-key';
import {
ROLE_PERMISSIONS,
ROLES,
@@ -10,6 +17,7 @@ import { createAuthKey, hash, secret } from '@/lib/crypto';
import { createSecureToken, parseSecureToken, parseToken } from '@/lib/jwt';
import redis from '@/lib/redis';
import { ensureArray } from '@/lib/utils';
+import { getApiKeyByHash, updateApiKeyLastUsed } from '@/queries/prisma/apiKey';
import { getUser } from '@/queries/prisma/user';
const log = debug('umami:auth');
@@ -20,8 +28,51 @@ export function getBearerToken(request: Request) {
return auth?.split(' ')[1];
}
+export async function checkApiKeyAuth(request: Request, token: string) {
+ const { pathname } = new URL(request.url);
+
+ if (isApiKeyBlockedPath(pathname)) {
+ log('API key not allowed for path', pathname);
+ return null;
+ }
+
+ const apiKey = await getApiKeyByHash(hashApiKey(token));
+
+ if (!apiKey) {
+ log('API key not found');
+ return null;
+ }
+
+ const user: any = await getUser(apiKey.userId);
+
+ if (!user?.id) {
+ log('API key user not found');
+ return null;
+ }
+
+ const lastUsedAt = apiKey.lastUsedAt?.getTime() ?? 0;
+
+ if (Date.now() - lastUsedAt > API_KEY_LAST_USED_INTERVAL) {
+ updateApiKeyLastUsed(apiKey.id).catch(e => log(e));
+ }
+
+ delete user.password;
+ user.isAdmin = user.role === ROLES.admin;
+
+ return {
+ token,
+ user,
+ apiKey: { id: apiKey.id, name: apiKey.name },
+ };
+}
+
export async function checkAuth(request: Request) {
const token = getBearerToken(request);
+
+ if (isApiKeyEnabled() && isApiKey(token)) {
+ return checkApiKeyAuth(request, token);
+ }
+
const payload = parseSecureToken(token, secret());
const shareToken = await parseShareToken(request);
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 98f47af6e..d6146ea11 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -19,6 +19,10 @@ export interface Auth {
role: string;
isAdmin: boolean;
};
+ apiKey?: {
+ id: string;
+ name: string;
+ };
shareToken?: {
shareType?: number;
websiteId?: string;
diff --git a/src/openapi/security.ts b/src/openapi/security.ts
index 4195fa44f..1f00e35e4 100644
--- a/src/openapi/security.ts
+++ b/src/openapi/security.ts
@@ -5,7 +5,8 @@ export const securitySchemes = {
type: 'http' as const,
scheme: 'bearer',
bearerFormat: 'JWT',
- description: 'Token returned by POST /api/auth/login.',
+ description:
+ 'Token returned by POST /api/auth/login, or an API key (`umami_…`) created under Settings → API keys (self-hosted only).',
},
shareToken: {
type: 'apiKey' as const,
diff --git a/src/queries/prisma/apiKey.ts b/src/queries/prisma/apiKey.ts
new file mode 100644
index 000000000..f67411721
--- /dev/null
+++ b/src/queries/prisma/apiKey.ts
@@ -0,0 +1,54 @@
+import prisma from '@/lib/prisma';
+
+export async function createApiKey(data: {
+ id: string;
+ userId: string;
+ name: string;
+ keyHash: string;
+ keyPrefix: string;
+}) {
+ return prisma.client.apiKey.create({
+ data,
+ select: {
+ id: true,
+ name: true,
+ keyPrefix: true,
+ createdAt: true,
+ },
+ });
+}
+
+export async function getApiKeyByHash(keyHash: string) {
+ return prisma.client.apiKey.findUnique({
+ where: { keyHash },
+ });
+}
+
+export async function getUserApiKeys(userId: string) {
+ return prisma.client.apiKey.findMany({
+ where: { userId },
+ select: {
+ id: true,
+ name: true,
+ keyPrefix: true,
+ lastUsedAt: true,
+ createdAt: true,
+ },
+ orderBy: { createdAt: 'desc' },
+ });
+}
+
+export async function deleteApiKey(id: string, userId: string) {
+ const { count } = await prisma.client.apiKey.deleteMany({
+ where: { id, userId },
+ });
+
+ return count;
+}
+
+export async function updateApiKeyLastUsed(id: string) {
+ return prisma.client.apiKey.update({
+ where: { id },
+ data: { lastUsedAt: new Date() },
+ });
+}
diff --git a/src/queries/prisma/index.ts b/src/queries/prisma/index.ts
index 1db320166..2ffc48ac8 100644
--- a/src/queries/prisma/index.ts
+++ b/src/queries/prisma/index.ts
@@ -1,4 +1,5 @@
export * from './annotation';
+export * from './apiKey';
export * from './board';
export * from './link';
export * from './pixel';