diff --git a/components/ModalContent/NewKeyModal.tsx b/components/ModalContent/NewKeyModal.tsx
new file mode 100644
index 0000000..e36f724
--- /dev/null
+++ b/components/ModalContent/NewKeyModal.tsx
@@ -0,0 +1,199 @@
+import React, { useState } from "react";
+import TextInput from "@/components/TextInput";
+import { KeyExpiry } from "@/types/global";
+import { useSession } from "next-auth/react";
+import toast from "react-hot-toast";
+import Modal from "../Modal";
+
+type Props = {
+ onClose: Function;
+};
+
+export default function NewKeyModal({ onClose }: Props) {
+ const { data } = useSession();
+
+ const initial = {
+ name: "",
+ expires: 0 as KeyExpiry,
+ };
+
+ const [key, setKey] = useState(initial as any);
+
+ const [submitLoader, setSubmitLoader] = useState(false);
+
+ const submit = async () => {
+ if (!submitLoader) {
+ setSubmitLoader(true);
+
+ let response;
+
+ const load = toast.loading("Creating...");
+
+ response = await fetch("/api/v1/tokens", {
+ method: "POST",
+ body: JSON.stringify({
+ name: key.name,
+ expires: key.expires,
+ }),
+ });
+
+ const data = await response.json();
+
+ toast.dismiss(load);
+
+ if (response.ok) {
+ toast.success(`Created!`);
+ onClose();
+ } else toast.error(data.response as string);
+
+ setSubmitLoader(false);
+
+ return response;
+ }
+ };
+
+ return (
+
+ Create an Access Token
+
+
+
+
+
+
Name
+
+
setKey({ ...key, name: e.target.value })}
+ placeholder="e.g. For the Mobile App"
+ className="bg-base-200"
+ />
+
+
+
+
Date of Expiry
+
+
+
+ {key.expires === KeyExpiry.sevenDays && "7 Days"}
+ {key.expires === KeyExpiry.oneMonth && "30 Days"}
+ {key.expires === KeyExpiry.twoMonths && "60 Days"}
+ {key.expires === KeyExpiry.threeMonths && "90 Days"}
+ {key.expires === KeyExpiry.never && "No Expiration"}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/SettingsSidebar.tsx b/components/SettingsSidebar.tsx
index d4558f8..3ffbc5f 100644
--- a/components/SettingsSidebar.tsx
+++ b/components/SettingsSidebar.tsx
@@ -64,16 +64,16 @@ export default function SettingsSidebar({ className }: { className?: string }) {
-
+
-
API Keys
+
Access Token
diff --git a/lib/api/controllers/tokens/getTokens.ts b/lib/api/controllers/tokens/getTokens.ts
new file mode 100644
index 0000000..d525ac7
--- /dev/null
+++ b/lib/api/controllers/tokens/getTokens.ts
@@ -0,0 +1,22 @@
+import { prisma } from "@/lib/api/db";
+import { KeyExpiry } from "@/types/global";
+import bcrypt from "bcrypt";
+import crypto from "crypto";
+
+export default async function getToken(userId: number) {
+ const getTokens = await prisma.apiKey.findMany({
+ where: {
+ userId,
+ },
+ select: {
+ name: true,
+ expires: true,
+ createdAt: true,
+ },
+ });
+
+ return {
+ response: getTokens,
+ status: 200,
+ };
+}
diff --git a/lib/api/controllers/tokens/postToken.ts b/lib/api/controllers/tokens/postToken.ts
new file mode 100644
index 0000000..24a8cba
--- /dev/null
+++ b/lib/api/controllers/tokens/postToken.ts
@@ -0,0 +1,77 @@
+import { prisma } from "@/lib/api/db";
+import { KeyExpiry } from "@/types/global";
+import bcrypt from "bcrypt";
+import crypto from "crypto";
+
+export default async function postToken(
+ body: {
+ name: string;
+ expires: KeyExpiry;
+ },
+ userId: number
+) {
+ console.log(body);
+
+ const checkHasEmptyFields = !body.name || body.expires === undefined;
+
+ if (checkHasEmptyFields)
+ return {
+ response: "Please fill out all the fields.",
+ status: 400,
+ };
+
+ const checkIfTokenExists = await prisma.apiKey.findFirst({
+ where: {
+ name: body.name,
+ userId,
+ },
+ });
+
+ if (checkIfTokenExists) {
+ return {
+ response: "Token with that name already exists.",
+ status: 400,
+ };
+ }
+
+ let expiryDate = new Date();
+
+ switch (body.expires) {
+ case KeyExpiry.sevenDays:
+ expiryDate.setDate(expiryDate.getDate() + 7);
+ break;
+ case KeyExpiry.oneMonth:
+ expiryDate.setDate(expiryDate.getDate() + 30);
+ break;
+ case KeyExpiry.twoMonths:
+ expiryDate.setDate(expiryDate.getDate() + 60);
+ break;
+ case KeyExpiry.threeMonths:
+ expiryDate.setDate(expiryDate.getDate() + 90);
+ break;
+ case KeyExpiry.never:
+ expiryDate.setDate(expiryDate.getDate() + 73000); // 200 years (not really never)
+ break;
+ default:
+ expiryDate.setDate(expiryDate.getDate() + 7);
+ break;
+ }
+
+ const saltRounds = 10;
+
+ const hashedKey = bcrypt.hashSync(crypto.randomUUID(), saltRounds);
+
+ const createToken = await prisma.apiKey.create({
+ data: {
+ name: body.name,
+ userId,
+ token: hashedKey,
+ expires: expiryDate,
+ },
+ });
+
+ return {
+ response: createToken.token,
+ status: 200,
+ };
+}
diff --git a/lib/api/controllers/tokens/tokenId/deleteTokenById.ts b/lib/api/controllers/tokens/tokenId/deleteTokenById.ts
new file mode 100644
index 0000000..742016f
--- /dev/null
+++ b/lib/api/controllers/tokens/tokenId/deleteTokenById.ts
@@ -0,0 +1,16 @@
+import { prisma } from "@/lib/api/db";
+import { KeyExpiry } from "@/types/global";
+
+export default async function deleteToken(userId: number, tokenId: number) {
+ if (!tokenId)
+ return { response: "Please choose a valid token.", status: 401 };
+
+ const deletedToken = await prisma.apiKey.delete({
+ where: {
+ id: tokenId,
+ userId,
+ },
+ });
+
+ return { response: deletedToken, status: 200 };
+}
diff --git a/pages/api/v1/tokens/[id].ts b/pages/api/v1/tokens/[id].ts
new file mode 100644
index 0000000..6c2e51b
--- /dev/null
+++ b/pages/api/v1/tokens/[id].ts
@@ -0,0 +1,13 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+import verifyUser from "@/lib/api/verifyUser";
+import deleteToken from "@/lib/api/controllers/tokens/tokenId/deleteTokenById";
+
+export default async function token(req: NextApiRequest, res: NextApiResponse) {
+ const user = await verifyUser({ req, res });
+ if (!user) return;
+
+ if (req.method === "DELETE") {
+ const deleted = await deleteToken(user.id, Number(req.query.id) as number);
+ return res.status(deleted.status).json({ response: deleted.response });
+ }
+}
diff --git a/pages/api/v1/tokens/index.ts b/pages/api/v1/tokens/index.ts
new file mode 100644
index 0000000..8af63fb
--- /dev/null
+++ b/pages/api/v1/tokens/index.ts
@@ -0,0 +1,20 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+import verifyUser from "@/lib/api/verifyUser";
+import postToken from "@/lib/api/controllers/tokens/postToken";
+import getTokens from "@/lib/api/controllers/tokens/getTokens";
+
+export default async function tokens(
+ req: NextApiRequest,
+ res: NextApiResponse
+) {
+ const user = await verifyUser({ req, res });
+ if (!user) return;
+
+ if (req.method === "POST") {
+ const token = await postToken(JSON.parse(req.body), user.id);
+ return res.status(token.status).json({ response: token.response });
+ } else if (req.method === "GET") {
+ const token = await getTokens(user.id);
+ return res.status(token.status).json({ response: token.response });
+ }
+}
diff --git a/pages/settings/access-tokens.tsx b/pages/settings/access-tokens.tsx
new file mode 100644
index 0000000..14912a2
--- /dev/null
+++ b/pages/settings/access-tokens.tsx
@@ -0,0 +1,35 @@
+import SettingsLayout from "@/layouts/SettingsLayout";
+import React, { useState } from "react";
+import NewKeyModal from "@/components/ModalContent/NewKeyModal";
+
+export default function Api() {
+ const [newKeyModal, setNewKeyModal] = useState(false);
+
+ return (
+
+ API Keys
+
+
+
+
+
+ Access Tokens can be used to access Linkwarden from other apps and
+ services without giving away your Username and Password.
+
+
+
+
+
+ {newKeyModal ? (
+ setNewKeyModal(false)} />
+ ) : undefined}
+
+ );
+}
diff --git a/pages/settings/api.tsx b/pages/settings/api.tsx
deleted file mode 100644
index dc4bb9a..0000000
--- a/pages/settings/api.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import Checkbox from "@/components/Checkbox";
-import SubmitButton from "@/components/SubmitButton";
-import SettingsLayout from "@/layouts/SettingsLayout";
-import React, { useEffect, useState } from "react";
-import useAccountStore from "@/store/account";
-import { toast } from "react-hot-toast";
-import { AccountSettings } from "@/types/global";
-import TextInput from "@/components/TextInput";
-
-export default function Api() {
- const [submitLoader, setSubmitLoader] = useState(false);
- const { account, updateAccount } = useAccountStore();
- const [user, setUser] = useState(account);
-
- const [archiveAsScreenshot, setArchiveAsScreenshot] =
- useState(false);
- const [archiveAsPDF, setArchiveAsPDF] = useState(false);
- const [archiveAsWaybackMachine, setArchiveAsWaybackMachine] =
- useState(false);
-
- useEffect(() => {
- setUser({
- ...account,
- archiveAsScreenshot,
- archiveAsPDF,
- archiveAsWaybackMachine,
- });
- }, [account, archiveAsScreenshot, archiveAsPDF, archiveAsWaybackMachine]);
-
- function objectIsEmpty(obj: object) {
- return Object.keys(obj).length === 0;
- }
-
- useEffect(() => {
- if (!objectIsEmpty(account)) {
- setArchiveAsScreenshot(account.archiveAsScreenshot);
- setArchiveAsPDF(account.archiveAsPDF);
- setArchiveAsWaybackMachine(account.archiveAsWaybackMachine);
- }
- }, [account]);
-
- const submit = async () => {
- // setSubmitLoader(true);
- // const load = toast.loading("Applying...");
- // const response = await updateAccount({
- // ...user,
- // });
- // toast.dismiss(load);
- // if (response.ok) {
- // toast.success("Settings Applied!");
- // } else toast.error(response.data as string);
- // setSubmitLoader(false);
- };
-
- return (
-
- API Keys (Soon)
-
-
-
-
-
- Status: Under Development
-
-
-
This page will be for creating and managing your API keys.
-
-
- For now, you can temporarily use your{" "}
-
- next-auth.session-token
-
{" "}
- in your browser cookies as the API key for your integrations.
-
-
-
- );
-}
diff --git a/prisma/migrations/20240113051701_make_key_names_unique/migration.sql b/prisma/migrations/20240113051701_make_key_names_unique/migration.sql
new file mode 100644
index 0000000..55efb95
--- /dev/null
+++ b/prisma/migrations/20240113051701_make_key_names_unique/migration.sql
@@ -0,0 +1,8 @@
+/*
+ Warnings:
+
+ - A unique constraint covering the columns `[name]` on the table `ApiKey` will be added. If there are existing duplicate values, this will fail.
+
+*/
+-- CreateIndex
+CREATE UNIQUE INDEX "ApiKey_name_key" ON "ApiKey"("name");
diff --git a/prisma/migrations/20240113060555_minor_fix/migration.sql b/prisma/migrations/20240113060555_minor_fix/migration.sql
new file mode 100644
index 0000000..d3999b6
--- /dev/null
+++ b/prisma/migrations/20240113060555_minor_fix/migration.sql
@@ -0,0 +1,14 @@
+/*
+ Warnings:
+
+ - A unique constraint covering the columns `[name,userId]` on the table `ApiKey` will be added. If there are existing duplicate values, this will fail.
+
+*/
+-- DropIndex
+DROP INDEX "ApiKey_name_key";
+
+-- DropIndex
+DROP INDEX "ApiKey_token_userId_key";
+
+-- CreateIndex
+CREATE UNIQUE INDEX "ApiKey_name_userId_key" ON "ApiKey"("name", "userId");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 3f10539..60a03e1 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -144,7 +144,7 @@ model Subscription {
model ApiKey {
id Int @id @default(autoincrement())
- name String
+ name String
user User @relation(fields: [userId], references: [id])
userId Int
token String @unique
@@ -153,5 +153,5 @@ model ApiKey {
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
- @@unique([token, userId])
+ @@unique([name, userId])
}
diff --git a/types/global.ts b/types/global.ts
index 8b3efcf..36e38c1 100644
--- a/types/global.ts
+++ b/types/global.ts
@@ -134,3 +134,11 @@ export enum LinkType {
pdf,
image,
}
+
+export enum KeyExpiry {
+ sevenDays,
+ oneMonth,
+ twoMonths,
+ threeMonths,
+ never,
+}