Add account API key management
This commit is contained in:
1 parent
98b824ce48
commit
f1c5b4f7bb
32 files changed
+1618
-15
No files matched your search
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ export interface Auth {
|
||||
role: string;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
apiKey?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
shareToken?: {
|
||||
shareType?: number;
|
||||
websiteId?: string;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
export * from './annotation';
|
||||
export * from './apiKey';
|
||||
export * from './board';
|
||||
export * from './link';
|
||||
export * from './pixel';
|
||||
|
||||
Reference in new issue
Block a user