Add account API key management

This commit is contained in:
Mike Cao committed 2026-09-01 21:50:07 -07:00
1 parent 98b824ce48
commit f1c5b4f7bb
32 files changed
+1618 -15

No files matched your search

+3
View File
@@ -208,6 +208,9 @@ if (isProd && cloudMode) {
/** @type {import('next').NextConfig} */
export default withNextIntl({
reactStrictMode: false,
devIndicators: {
position: 'bottom-right',
},
env: {
apiUrl,
basePath,
@@ -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;
+16
View File
@@ -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
+10 -2
View File
@@ -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."
}
}
+357 -2
View File
@@ -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"
+8 -7
View File
@@ -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: <Settings2 />,
},
],
},
{
label: t(labels.account),
items: [
{
id: 'profile',
label: t(labels.profile),
@@ -47,6 +42,12 @@ export function SettingsNav({
path: renderUrl('/settings/security'),
icon: <ShieldCheck />,
},
{
id: 'api-keys',
label: t(labels.apiKeys),
path: renderUrl('/settings/api-keys'),
icon: <KeyRound />,
},
],
},
];
@@ -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 (
<DialogTrigger>
<Button variant="primary">
<Icon>
<Plus />
</Icon>
<Text>{t(labels.createApiKey)}</Text>
</Button>
<Modal>
<Dialog title={t(labels.createApiKey)} style={{ width: 480 }}>
{({ close }) => <ApiKeyAddForm onSave={handleSave} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>
);
}
@@ -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<string | null>(null);
const handleSubmit = async (data: any) => {
await mutateAsync(data, {
onSuccess: async result => {
setCreatedKey(result.key);
onSave?.();
},
});
};
if (createdKey) {
return (
<Column gap="4">
<Alert>
<Icon>
<AlertTriangle />
</Icon>
<AlertTitle>{t(messages.apiKeyCreated)}</AlertTitle>
</Alert>
<Row alignItems="center" gap="2">
<TextField value={createdKey} isReadOnly style={{ flex: 1, fontFamily: 'monospace' }} />
<CopyButton value={createdKey} label={t(labels.copy)} />
</Row>
<Row justifyContent="flex-end">
<Button variant="primary" onPress={onClose}>
{t(labels.close)}
</Button>
</Row>
</Column>
);
}
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
<FormField name="name" label={t(labels.name)}>
<TextField autoComplete="off" autoFocus />
</FormField>
<FormButtons>
<Button isDisabled={isPending} onPress={onClose}>
{t(labels.cancel)}
</Button>
<FormSubmitButton variant="primary" isDisabled={isPending}>
{t(labels.save)}
</FormSubmitButton>
</FormButtons>
</Form>
);
}
@@ -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 (
<DialogButton icon={<Trash />} title={t(labels.confirm)} variant="quiet" width="400px">
{({ close }) => (
<ConfirmationForm
message={t.rich(messages.confirmRemove, {
target: name,
b: chunks => <b>{chunks}</b>,
})}
isLoading={isPending}
error={getErrorMessage(error)}
onConfirm={handleConfirm.bind(null, close)}
onClose={close}
buttonLabel={t(labels.delete)}
buttonVariant="danger"
/>
)}
</DialogButton>
);
}
@@ -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 (
<PageBody>
<Column gap="6">
<PageHeader title={t(labels.apiKeys)}>
<ApiKeyAddButton />
</PageHeader>
<Panel>
<ApiKeysTable />
</Panel>
</Column>
</PageBody>
);
}
@@ -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 (
<LoadingPanel
data={data}
isLoading={isLoading}
error={error}
isEmpty={!data?.length}
renderEmpty={() => (
<EmptyPlaceholder icon={<KeyRound />} description={t(messages.noApiKeys)} />
)}
>
<DataTable data={data ?? []}>
<DataColumn id="name" label={t(labels.name)} />
<DataColumn id="keyPrefix" label={t(labels.apiKey)}>
{(row: ApiKeyData) => <code>{row.keyPrefix}…</code>}
</DataColumn>
<DataColumn id="createdAt" label={t(labels.created)}>
{(row: ApiKeyData) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
<DataColumn id="lastUsedAt" label={t(labels.lastUsed)}>
{(row: ApiKeyData) =>
row.lastUsedAt ? (
<DateDistance date={new Date(row.lastUsedAt)} />
) : (
<Text color="muted">{t(labels.never)}</Text>
)
}
</DataColumn>
<DataColumn id="action" label=" " align="end" width="48px">
{(row: ApiKeyData) => <ApiKeyDeleteButton keyId={row.id} name={row.name} />}
</DataColumn>
</DataTable>
</LoadingPanel>
);
}
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from 'next';
import { ApiKeysPage } from './ApiKeysPage';
export default function () {
return <ApiKeysPage />;
}
export const metadata: Metadata = {
title: 'API keys',
};
@@ -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;
@@ -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();
});
+26
View File
@@ -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();
}
@@ -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;
+100
View File
@@ -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();
});
+49
View File
@@ -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 });
}
+12
View File
@@ -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: {
@@ -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',
],
},
+1
View File
@@ -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';
@@ -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<ApiKeyData[]>({
queryKey: ['api-keys', { modified }],
queryFn: () => get('/me/api-keys'),
});
}
+8
View File
@@ -430,6 +430,12 @@ export const labels: Record<string, string> = {
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<string, string> = {
@@ -511,4 +517,6 @@ export const messages: Record<string, string> = {
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',
};
@@ -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 && (
<Alert variant="danger">
<AlertTitle>{t(messages.error)}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
<AlertTitle>{error}</AlertTitle>
</Alert>
)}
+89
View File
@@ -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);
});
});
+42
View File
@@ -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;
}
+126
View File
@@ -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);
+51
View File
@@ -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);
+4
View File
@@ -19,6 +19,10 @@ export interface Auth {
role: string;
isAdmin: boolean;
};
apiKey?: {
id: string;
name: string;
};
shareToken?: {
shareType?: number;
websiteId?: string;
+2 -1
View File
@@ -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,
+54
View File
@@ -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() },
});
}
+1
View File
@@ -1,4 +1,5 @@
export * from './annotation';
export * from './apiKey';
export * from './board';
export * from './link';
export * from './pixel';