diff --git a/README.md b/README.md
index 96adb11..fe6d3e4 100644
--- a/README.md
+++ b/README.md
@@ -40,5 +40,5 @@ Check out our [Next.js deployment documentation](https://nextjs.org/docs/deploym
CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, address TEXT NOT NULL, alias TEXT NOT NULL, pending INTEGER DEFAULT 0, temporary INTEGER DEFAULT 0);
CREATE TABLE temp_alias_requests (key TEXT PRIMARY KEY, address TEXT NOT NULL, alias TEXT NOT NULL, expires INTEGER NOT NULL);
-CREATE TABLE api_keys (id INTEGER PRIMARY KEY AUTOINCREMENT, address TEXT NOT NULL, token TEXT NOT NULL);
+CREATE TABLE api_keys (id INTEGER PRIMARY KEY AUTOINCREMENT, address TEXT NOT NULL, label TEXT NOT NULL, token TEXT NOT NULL);
```
diff --git a/src/app/(dashboard)/api-keys/page.tsx b/src/app/(dashboard)/api-keys/page.tsx
index 2fafa82..b65dfd7 100644
--- a/src/app/(dashboard)/api-keys/page.tsx
+++ b/src/app/(dashboard)/api-keys/page.tsx
@@ -1,8 +1,25 @@
-import { Button, Flex, Heading, Text } from "@radix-ui/themes";
-import { ExternalLinkIcon } from "lucide-react";
+"use client";
+
+import { createApiKey, deleteOwnApikey, fetchOwnApiKeys } from "@/lib/actions";
+import GenericConfirmationDialog from "@/lib/components/ui/GenericConfirmationDialog";
+import GhostMessage from "@/lib/components/ui/GhostMessage";
+import LoadingSpinner from "@/lib/components/ui/LoadingSpinner";
+import { ApiKeyEntry } from "@/lib/db";
+import { ToastContext } from "@/lib/providers/ToastProvider";
+import { Box, Button, Card, Dialog, Flex, Heading, Table, Text, TextField } from "@radix-ui/themes";
+import { ExternalLinkIcon, KeyIcon, PencilIcon, PlusIcon } from "lucide-react";
import Link from "next/link";
+import { useContext, useEffect, useState } from "react";
export default function ApiKeys() {
+ const [keys, setKeys] = useState<(ApiKeyEntry & { copyable?: true })[] | null>(null);
+ const [keyLabel, setKeyLabel] = useState("");
+ const toast = useContext(ToastContext);
+
+ useEffect(() => {
+ fetchOwnApiKeys().then(setKeys);
+ }, []);
+
return (
<>
@@ -10,10 +27,131 @@ export default function ApiKeys() {
API keys
Generate long lasting API keys that can be used to perform certain actions programatically.
-
-
-
+
+ !open && setKeyLabel("")}>
+
+
+
+
+ New API key
+
+ What is this key for?
+
+
+
+
+ setKeyLabel(e.currentTarget.value.substring(0, 64))}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ keys
+ ? keys.length
+ ?
+
+
+ Label
+ Key
+ Actions
+
+
+
+
+ {
+ keys.map((key) => (
+
+ {key.label}
+ {key.token}
+
+
+ {key.copyable && (
+
+ )}
+ {
+ try {
+ await deleteOwnApikey(key.id);
+ setKeys(keys.filter((k) => k.id != key.id));
+ toast({
+ title: "Key deleted",
+ variant: "success",
+ });
+ } catch(e) {
+ toast({
+ title: "Failed to delete key",
+ description: `${e}`,
+ variant: "error",
+ });
+ }
+ }}
+ labelConfirm="Delete"
+ >
+
+
+
+
+
+ ))
+ }
+
+
+ :
+ } header="No API keys" message="You don't have any API keys right now" />
+
+ : } header="Loading" />
+ }
+
>
);
}
\ No newline at end of file
diff --git a/src/lib/actions.ts b/src/lib/actions.ts
index da5292f..3e51624 100644
--- a/src/lib/actions.ts
+++ b/src/lib/actions.ts
@@ -5,8 +5,8 @@ import fs from "fs/promises";
import { getServerSession } from "next-auth";
import * as random_words from "random-words";
import { AuditLog, auditLog } from "./audit";
-import { AliasEntry, AliasRequestEntry, approveAliasEntry, createAliasEntry, createTempAliasRequestEntry, createUserEntry, database, deleteAliasEntry, deleteTempAliasRequestEntry, getAlias, getAllAliases, getTempAliasRequestEntry, getUserAliases, setUserPassword } from "./db";
-import { aliasesNeedApproval, isAdmin } from "./util";
+import { AliasEntry, AliasRequestEntry, ApiKeyEntry, approveAliasEntry, createAliasEntry, createApiKeyEntry, createTempAliasRequestEntry, createUserEntry, database, deleteAliasEntry, deleteApiKey, deleteTempAliasRequestEntry, getAlias, getAllAliases, getApiKeyById, getTempAliasRequestEntry, getUserAliases, getUserApiKeys, setUserPassword } from "./db";
+import { aliasesNeedApproval, anonymizeApiKey, isAdmin } from "./util";
import { TEMP_EMAIL_DOMAIN } from "./constants";
export async function fetchAllUsers(): Promise {
@@ -279,3 +279,44 @@ export async function fetchAuditLog(page: number): Promise<{ page: number, perPa
.map((item, i) => ({ ...JSON.parse(item), index: page * itemsPerPage + i })),
};
}
+
+export async function createApiKey(label: string): Promise {
+ const session = await getServerSession();
+ if (!session?.user?.email) throw new Error("Unauthenticated");
+
+ if (!label.length || label.length > 64) throw new Error("Malformed label");
+
+ const token = crypto.randomBytes(48).toString("base64");
+ const id = await createApiKeyEntry(session.user.email, label, token);
+
+ const key: ApiKeyEntry = {
+ id: id,
+ address: session.user.email,
+ label: label,
+ token: token,
+ };
+
+ auditLog('createApiKey', anonymizeApiKey(key));
+
+ return key;
+}
+
+export async function fetchOwnApiKeys(): Promise {
+ const session = await getServerSession();
+ if (!session?.user?.email) throw new Error("Unauthenticated");
+
+ const keys = await getUserApiKeys(session.user.email);
+ return keys.map(anonymizeApiKey);
+}
+
+export async function deleteOwnApikey(id: number) {
+ const session = await getServerSession();
+ if (!session?.user?.email) throw new Error("Unauthenticated");
+
+ const key = await getApiKeyById(id);
+ if (!key || key.address != session.user.email) throw new Error("Unauthorized");
+
+ await deleteApiKey(id);
+
+ auditLog('deleteApiKey', anonymizeApiKey(key));
+}
diff --git a/src/lib/audit.ts b/src/lib/audit.ts
index 7eb1792..3f9e571 100644
--- a/src/lib/audit.ts
+++ b/src/lib/audit.ts
@@ -2,6 +2,7 @@ import { getServerSession } from "next-auth";
import fs from "fs/promises";
export type AuditLogAction = 'login' | 'changeOwnPassword' | 'requestAlias' | 'deleteAlias' | 'createTempAlias' // Unprivileged
+ | 'createApiKey' | 'deleteApiKey'
| 'createUser' | 'createAlias' | 'approveAlias' | 'deleteAlias'; // Privileged
export type AuditLog = {
diff --git a/src/lib/db.ts b/src/lib/db.ts
index bc51921..67aa973 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -4,6 +4,7 @@ import { PHASE_PRODUCTION_BUILD } from "next/dist/shared/lib/constants";
export type AliasEntry = { id: number, address: string, alias: string, pending: boolean, temporary: boolean };
export type AliasRequestEntry = { key: string, address: string, alias: string, expires: number };
+export type ApiKeyEntry = { id: number, address: string, label: string, token: string };
export const database = (type: 'credentials' | 'aliases') => {
if (process.env.NEXT_PHASE != PHASE_PRODUCTION_BUILD) {
@@ -232,3 +233,82 @@ export function deleteAliasEntry(alias: string) {
});
});
}
+
+export function createApiKeyEntry(address: string, label: string, token: string) {
+ return new Promise(async (resolve, reject) => {
+ const db = database('credentials');
+
+ db.run(
+ "INSERT INTO api_keys (address, label, token) VALUES (?1, ?2, ?3)",
+ {
+ 1: address,
+ 2: label,
+ 3: token,
+ },
+ function (err: any) {
+ db.close();
+ if (err) return reject(err);
+ resolve(this.lastID);
+ });
+ });
+}
+
+export function getUserApiKeys(address: string) {
+ return new Promise(async (resolve, reject) => {
+ const db = database('credentials');
+
+ db.all(
+ "SELECT id, address, label, token FROM api_keys WHERE address = ?",
+ address,
+ function (err: any, res: any) {
+ db.close();
+ if (err) return reject(err);
+ resolve(res);
+ });
+ });
+}
+
+export function getApiKeyByToken(token: string) {
+ return new Promise(async (resolve, reject) => {
+ const db = database('credentials');
+
+ db.get(
+ "SELECT id, address, label, token FROM api_keys WHERE token = ?",
+ token,
+ function (err: any, res: any) {
+ db.close();
+ if (err) return reject(err);
+ resolve(res);
+ });
+ });
+}
+
+export function getApiKeyById(id: number) {
+ return new Promise(async (resolve, reject) => {
+ const db = database('credentials');
+
+ db.get(
+ "SELECT id, address, label, token FROM api_keys WHERE id = ?",
+ id,
+ function (err: any, res: any) {
+ db.close();
+ if (err) return reject(err);
+ resolve(res);
+ });
+ });
+}
+
+export function deleteApiKey(id: number) {
+ return new Promise(async (resolve, reject) => {
+ const db = database('credentials');
+
+ db.run(
+ "DELETE FROM api_keys WHERE id = ?",
+ id,
+ function (err: any) {
+ db.close();
+ if (err) return reject(err);
+ resolve();
+ });
+ });
+}
diff --git a/src/lib/providers/ToastProvider.tsx b/src/lib/providers/ToastProvider.tsx
index d9a9f6b..96180da 100644
--- a/src/lib/providers/ToastProvider.tsx
+++ b/src/lib/providers/ToastProvider.tsx
@@ -54,7 +54,7 @@ function Toast({ toast, onClosed }: { toast: ToastData, onClosed: () => void })
return (
!state && onClosed()}
>
diff --git a/src/lib/util.ts b/src/lib/util.ts
index bcc32ac..8def298 100644
--- a/src/lib/util.ts
+++ b/src/lib/util.ts
@@ -1,5 +1,6 @@
import { Session } from 'next-auth';
import crypto from 'crypto';
+import { ApiKeyEntry } from './db';
export function sha256sum(input: any) {
const hash = crypto.createHash('sha256');
@@ -15,3 +16,10 @@ export function isAdmin(session: Session | string | null) {
export function aliasesNeedApproval(session: Session | null) {
return !isAdmin(session); // also todo
}
+
+export function anonymizeApiKey(key: ApiKeyEntry): ApiKeyEntry {
+ return {
+ ...key,
+ token: key.token.substring(0, 6) + "********",
+ };
+}