finalized adding support for access tokens

This commit is contained in:
daniel31x13 2024-01-24 15:48:40 -05:00
parent 05563134b4
commit 5be194235c
18 changed files with 366 additions and 106 deletions

View File

@ -1,25 +1,25 @@
import React, { useState } from "react"; import React, { useState } from "react";
import TextInput from "@/components/TextInput"; import TextInput from "@/components/TextInput";
import { KeyExpiry } from "@/types/global"; import { TokenExpiry } from "@/types/global";
import { useSession } from "next-auth/react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import Modal from "../Modal"; import Modal from "../Modal";
import useTokenStore from "@/store/tokens";
type Props = { type Props = {
onClose: Function; onClose: Function;
}; };
export default function NewKeyModal({ onClose }: Props) { export default function NewTokenModal({ onClose }: Props) {
const { data } = useSession();
const [newToken, setNewToken] = useState(""); const [newToken, setNewToken] = useState("");
const { addToken } = useTokenStore();
const initial = { const initial = {
name: "", name: "",
expires: 0 as KeyExpiry, expires: 0 as TokenExpiry,
}; };
const [key, setKey] = useState(initial as any); const [token, setToken] = useState(initial as any);
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
@ -27,30 +27,18 @@ export default function NewKeyModal({ onClose }: Props) {
if (!submitLoader) { if (!submitLoader) {
setSubmitLoader(true); setSubmitLoader(true);
let response;
const load = toast.loading("Creating..."); const load = toast.loading("Creating...");
response = await fetch("/api/v1/tokens", { const { ok, data } = await addToken(token);
method: "POST",
body: JSON.stringify({
name: key.name,
expires: key.expires,
}),
});
const data = await response.json();
toast.dismiss(load); toast.dismiss(load);
if (response.ok) { if (ok) {
toast.success(`Created!`); toast.success(`Created!`);
setNewToken(data.response); setNewToken((data as any).secretKey);
} else toast.error(data.response as string); } else toast.error(data as string);
setSubmitLoader(false); setSubmitLoader(false);
return response;
} }
}; };
@ -90,8 +78,8 @@ export default function NewKeyModal({ onClose }: Props) {
<p className="mb-2">Name</p> <p className="mb-2">Name</p>
<TextInput <TextInput
value={key.name} value={token.name}
onChange={(e) => setKey({ ...key, name: e.target.value })} onChange={(e) => setToken({ ...token, name: e.target.value })}
placeholder="e.g. For the iOS shortcut" placeholder="e.g. For the iOS shortcut"
className="bg-base-200" className="bg-base-200"
/> />
@ -106,11 +94,11 @@ export default function NewKeyModal({ onClose }: Props) {
role="button" role="button"
className="btn btn-outline w-36 flex items-center btn-sm h-10" className="btn btn-outline w-36 flex items-center btn-sm h-10"
> >
{key.expires === KeyExpiry.sevenDays && "7 Days"} {token.expires === TokenExpiry.sevenDays && "7 Days"}
{key.expires === KeyExpiry.oneMonth && "30 Days"} {token.expires === TokenExpiry.oneMonth && "30 Days"}
{key.expires === KeyExpiry.twoMonths && "60 Days"} {token.expires === TokenExpiry.twoMonths && "60 Days"}
{key.expires === KeyExpiry.threeMonths && "90 Days"} {token.expires === TokenExpiry.threeMonths && "90 Days"}
{key.expires === KeyExpiry.never && "No Expiration"} {token.expires === TokenExpiry.never && "No Expiration"}
</div> </div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl w-52 mt-1"> <ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl w-52 mt-1">
<li> <li>
@ -123,10 +111,13 @@ export default function NewKeyModal({ onClose }: Props) {
type="radio" type="radio"
name="sort-radio" name="sort-radio"
className="radio checked:bg-primary" className="radio checked:bg-primary"
checked={key.expires === KeyExpiry.sevenDays} checked={token.expires === TokenExpiry.sevenDays}
onChange={() => { onChange={() => {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
setKey({ ...key, expires: KeyExpiry.sevenDays }); setToken({
...token,
expires: TokenExpiry.sevenDays,
});
}} }}
/> />
<span className="label-text">7 Days</span> <span className="label-text">7 Days</span>
@ -142,10 +133,10 @@ export default function NewKeyModal({ onClose }: Props) {
type="radio" type="radio"
name="sort-radio" name="sort-radio"
className="radio checked:bg-primary" className="radio checked:bg-primary"
checked={key.expires === KeyExpiry.oneMonth} checked={token.expires === TokenExpiry.oneMonth}
onChange={() => { onChange={() => {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
setKey({ ...key, expires: KeyExpiry.oneMonth }); setToken({ ...token, expires: TokenExpiry.oneMonth });
}} }}
/> />
<span className="label-text">30 Days</span> <span className="label-text">30 Days</span>
@ -161,10 +152,13 @@ export default function NewKeyModal({ onClose }: Props) {
type="radio" type="radio"
name="sort-radio" name="sort-radio"
className="radio checked:bg-primary" className="radio checked:bg-primary"
checked={key.expires === KeyExpiry.twoMonths} checked={token.expires === TokenExpiry.twoMonths}
onChange={() => { onChange={() => {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
setKey({ ...key, expires: KeyExpiry.twoMonths }); setToken({
...token,
expires: TokenExpiry.twoMonths,
});
}} }}
/> />
<span className="label-text">60 Days</span> <span className="label-text">60 Days</span>
@ -180,10 +174,13 @@ export default function NewKeyModal({ onClose }: Props) {
type="radio" type="radio"
name="sort-radio" name="sort-radio"
className="radio checked:bg-primary" className="radio checked:bg-primary"
checked={key.expires === KeyExpiry.threeMonths} checked={token.expires === TokenExpiry.threeMonths}
onChange={() => { onChange={() => {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
setKey({ ...key, expires: KeyExpiry.threeMonths }); setToken({
...token,
expires: TokenExpiry.threeMonths,
});
}} }}
/> />
<span className="label-text">90 Days</span> <span className="label-text">90 Days</span>
@ -199,10 +196,10 @@ export default function NewKeyModal({ onClose }: Props) {
type="radio" type="radio"
name="sort-radio" name="sort-radio"
className="radio checked:bg-primary" className="radio checked:bg-primary"
checked={key.expires === KeyExpiry.never} checked={token.expires === TokenExpiry.never}
onChange={() => { onChange={() => {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
setKey({ ...key, expires: KeyExpiry.never }); setToken({ ...token, expires: TokenExpiry.never });
}} }}
/> />
<span className="label-text">No Expiration</span> <span className="label-text">No Expiration</span>

View File

@ -0,0 +1,62 @@
import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { useRouter } from "next/router";
import { AccessToken } from "@prisma/client";
import useTokenStore from "@/store/tokens";
type Props = {
onClose: Function;
activeToken: AccessToken;
};
export default function DeleteTokenModal({ onClose, activeToken }: Props) {
const [token, setToken] = useState<AccessToken>(activeToken);
const { revokeToken } = useTokenStore();
const [submitLoader, setSubmitLoader] = useState(false);
const router = useRouter();
useEffect(() => {
setToken(activeToken);
}, []);
const deleteLink = async () => {
console.log(token);
const load = toast.loading("Deleting...");
const response = await revokeToken(token.id as number);
toast.dismiss(load);
response.ok && toast.success(`Token Revoked.`);
onClose();
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">Revoke Token</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<p>
Are you sure you want to revoke this Access Token? Any apps or
services using this token will no longer be able to access Linkwarden
using it.
</p>
<button
className={`ml-auto btn w-fit text-white flex items-center gap-2 duration-100 bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer`}
onClick={deleteLink}
>
<i className="bi-trash text-xl" />
Revoke
</button>
</div>
</Modal>
);
}

View File

@ -73,7 +73,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} } duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<i className="bi-key text-primary text-2xl"></i> <i className="bi-key text-primary text-2xl"></i>
<p className="truncate w-full pr-7">Access Token</p> <p className="truncate w-full pr-7">Access Tokens</p>
</div> </div>
</Link> </Link>

View File

@ -1,14 +1,13 @@
import { prisma } from "@/lib/api/db"; 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) { export default async function getToken(userId: number) {
const getTokens = await prisma.apiKey.findMany({ const getTokens = await prisma.accessToken.findMany({
where: { where: {
userId, userId,
revoked: false,
}, },
select: { select: {
id: true,
name: true, name: true,
expires: true, expires: true,
createdAt: true, createdAt: true,

View File

@ -1,12 +1,12 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { KeyExpiry } from "@/types/global"; import { TokenExpiry } from "@/types/global";
import crypto from "crypto"; import crypto from "crypto";
import { decode, encode, getToken } from "next-auth/jwt"; import { decode, encode } from "next-auth/jwt";
export default async function postToken( export default async function postToken(
body: { body: {
name: string; name: string;
expires: KeyExpiry; expires: TokenExpiry;
}, },
userId: number userId: number
) { ) {
@ -20,9 +20,10 @@ export default async function postToken(
status: 400, status: 400,
}; };
const checkIfTokenExists = await prisma.apiKey.findFirst({ const checkIfTokenExists = await prisma.accessToken.findFirst({
where: { where: {
name: body.name, name: body.name,
revoked: false,
userId, userId,
}, },
}); });
@ -39,16 +40,16 @@ export default async function postToken(
const oneDayInSeconds = 86400; const oneDayInSeconds = 86400;
let expiryDateSecond = 7 * oneDayInSeconds; let expiryDateSecond = 7 * oneDayInSeconds;
if (body.expires === KeyExpiry.oneMonth) { if (body.expires === TokenExpiry.oneMonth) {
expiryDate.setDate(expiryDate.getDate() + 30); expiryDate.setDate(expiryDate.getDate() + 30);
expiryDateSecond = 30 * oneDayInSeconds; expiryDateSecond = 30 * oneDayInSeconds;
} else if (body.expires === KeyExpiry.twoMonths) { } else if (body.expires === TokenExpiry.twoMonths) {
expiryDate.setDate(expiryDate.getDate() + 60); expiryDate.setDate(expiryDate.getDate() + 60);
expiryDateSecond = 60 * oneDayInSeconds; expiryDateSecond = 60 * oneDayInSeconds;
} else if (body.expires === KeyExpiry.threeMonths) { } else if (body.expires === TokenExpiry.threeMonths) {
expiryDate.setDate(expiryDate.getDate() + 90); expiryDate.setDate(expiryDate.getDate() + 90);
expiryDateSecond = 90 * oneDayInSeconds; expiryDateSecond = 90 * oneDayInSeconds;
} else if (body.expires === KeyExpiry.never) { } else if (body.expires === TokenExpiry.never) {
expiryDate.setDate(expiryDate.getDate() + 73000); // 200 years (not really never) expiryDate.setDate(expiryDate.getDate() + 73000); // 200 years (not really never)
expiryDateSecond = 73050 * oneDayInSeconds; expiryDateSecond = 73050 * oneDayInSeconds;
} else { } else {
@ -72,7 +73,7 @@ export default async function postToken(
secret: process.env.NEXTAUTH_SECRET, secret: process.env.NEXTAUTH_SECRET,
}); });
const createToken = await prisma.apiKey.create({ const createToken = await prisma.accessToken.create({
data: { data: {
name: body.name, name: body.name,
userId, userId,
@ -82,7 +83,10 @@ export default async function postToken(
}); });
return { return {
response: token, response: {
secretKey: token,
token: createToken,
},
status: 200, status: 200,
}; };
} }

View File

@ -1,16 +1,24 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { KeyExpiry } from "@/types/global";
export default async function deleteToken(userId: number, tokenId: number) { export default async function deleteToken(userId: number, tokenId: number) {
if (!tokenId) if (!tokenId)
return { response: "Please choose a valid token.", status: 401 }; return { response: "Please choose a valid token.", status: 401 };
const deletedToken = await prisma.apiKey.delete({ const tokenExists = await prisma.accessToken.findFirst({
where: { where: {
id: tokenId, id: tokenId,
userId, userId,
}, },
}); });
return { response: deletedToken, status: 200 }; const revokedToken = await prisma.accessToken.update({
where: {
id: tokenExists?.id,
},
data: {
revoked: true,
},
});
return { response: revokedToken, status: 200 };
} }

36
lib/api/verifyToken.ts Normal file
View File

@ -0,0 +1,36 @@
import { NextApiRequest } from "next";
import { JWT, getToken } from "next-auth/jwt";
import { prisma } from "./db";
type Props = {
req: NextApiRequest;
};
export default async function verifyToken({
req,
}: Props): Promise<JWT | string> {
const token = await getToken({ req });
const userId = token?.id;
if (!userId) {
return "You must be logged in.";
}
if (token.exp < Date.now() / 1000) {
return "Your session has expired, please log in again.";
}
// check if token is revoked
const revoked = await prisma.accessToken.findFirst({
where: {
token: token.jti,
revoked: true,
},
});
if (revoked) {
return "Your session has expired, please log in again.";
}
return token;
}

View File

@ -1,8 +1,8 @@
import { NextApiRequest, NextApiResponse } from "next"; import { NextApiRequest, NextApiResponse } from "next";
import { getToken } from "next-auth/jwt";
import { prisma } from "./db"; import { prisma } from "./db";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import verifySubscription from "./verifySubscription"; import verifySubscription from "./verifySubscription";
import verifyToken from "./verifyToken";
type Props = { type Props = {
req: NextApiRequest; req: NextApiRequest;
@ -15,21 +15,15 @@ export default async function verifyUser({
req, req,
res, res,
}: Props): Promise<User | null> { }: Props): Promise<User | null> {
const token = await getToken({ req }); const token = await verifyToken({ req });
if (typeof token === "string") {
res.status(401).json({ response: token });
return null;
}
const userId = token?.id; const userId = token?.id;
if (!userId) {
res.status(401).json({ response: "You must be logged in." });
return null;
}
if (token.exp < Date.now() / 1000) {
res
.status(401)
.json({ response: "Your session has expired, please log in again." });
return null;
}
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: {
id: userId, id: userId,

View File

@ -1,6 +1,5 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import readFile from "@/lib/api/storage/readFile"; import readFile from "@/lib/api/storage/readFile";
import { getToken } from "next-auth/jwt";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { ArchivedFormat } from "@/types/global"; import { ArchivedFormat } from "@/types/global";
import verifyUser from "@/lib/api/verifyUser"; import verifyUser from "@/lib/api/verifyUser";
@ -9,6 +8,7 @@ import { UsersAndCollections } from "@prisma/client";
import formidable from "formidable"; import formidable from "formidable";
import createFile from "@/lib/api/storage/createFile"; import createFile from "@/lib/api/storage/createFile";
import fs from "fs"; import fs from "fs";
import verifyToken from "@/lib/api/verifyToken";
export const config = { export const config = {
api: { api: {
@ -33,8 +33,8 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
return res.status(401).json({ response: "Invalid parameters." }); return res.status(401).json({ response: "Invalid parameters." });
if (req.method === "GET") { if (req.method === "GET") {
const token = await getToken({ req }); const token = await verifyToken({ req });
const userId = token?.id; const userId = typeof token === "string" ? undefined : token?.id;
const collectionIsAccessible = await prisma.collection.findFirst({ const collectionIsAccessible = await prisma.collection.findFirst({
where: { where: {

View File

@ -1,7 +1,7 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import readFile from "@/lib/api/storage/readFile"; import readFile from "@/lib/api/storage/readFile";
import { getToken } from "next-auth/jwt"; import verifyToken from "@/lib/api/verifyToken";
export default async function Index(req: NextApiRequest, res: NextApiResponse) { export default async function Index(req: NextApiRequest, res: NextApiResponse) {
const queryId = Number(req.query.id); const queryId = Number(req.query.id);
@ -12,8 +12,8 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
.status(401) .status(401)
.send("Invalid parameters."); .send("Invalid parameters.");
const token = await getToken({ req }); const token = await verifyToken({ req });
const userId = token?.id; const userId = typeof token === "string" ? undefined : token?.id;
if (req.method === "GET") { if (req.method === "GET") {
const targetUser = await prisma.user.findUnique({ const targetUser = await prisma.user.findUnique({

View File

@ -1,10 +1,10 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import getPublicUser from "@/lib/api/controllers/public/users/getPublicUser"; import getPublicUser from "@/lib/api/controllers/public/users/getPublicUser";
import { getToken } from "next-auth/jwt"; import verifyToken from "@/lib/api/verifyToken";
export default async function users(req: NextApiRequest, res: NextApiResponse) { export default async function users(req: NextApiRequest, res: NextApiResponse) {
const token = await getToken({ req }); const token = await verifyToken({ req });
const requestingId = token?.id; const requestingId = typeof token === "string" ? undefined : token?.id;
const lookupId = req.query.id as string; const lookupId = req.query.id as string;

View File

@ -2,26 +2,22 @@ import type { NextApiRequest, NextApiResponse } from "next";
import getUserById from "@/lib/api/controllers/users/userId/getUserById"; import getUserById from "@/lib/api/controllers/users/userId/getUserById";
import updateUserById from "@/lib/api/controllers/users/userId/updateUserById"; import updateUserById from "@/lib/api/controllers/users/userId/updateUserById";
import deleteUserById from "@/lib/api/controllers/users/userId/deleteUserById"; import deleteUserById from "@/lib/api/controllers/users/userId/deleteUserById";
import { getToken } from "next-auth/jwt";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import verifySubscription from "@/lib/api/verifySubscription"; import verifySubscription from "@/lib/api/verifySubscription";
import verifyToken from "@/lib/api/verifyToken";
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
export default async function users(req: NextApiRequest, res: NextApiResponse) { export default async function users(req: NextApiRequest, res: NextApiResponse) {
const token = await getToken({ req }); const token = await verifyToken({ req });
if (typeof token === "string") {
res.status(401).json({ response: token });
return null;
}
const userId = token?.id; const userId = token?.id;
if (!userId) {
return res.status(401).json({ response: "You must be logged in." });
}
if (token.exp < Date.now() / 1000) {
return res
.status(401)
.json({ response: "Your session has expired, please log in again." });
}
if (userId !== Number(req.query.id)) if (userId !== Number(req.query.id))
return res.status(401).json({ response: "Permission denied." }); return res.status(401).json({ response: "Permission denied." });

View File

@ -1,13 +1,33 @@
import SettingsLayout from "@/layouts/SettingsLayout"; import SettingsLayout from "@/layouts/SettingsLayout";
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import NewKeyModal from "@/components/ModalContent/NewKeyModal"; import NewTokenModal from "@/components/ModalContent/NewTokenModal";
import RevokeTokenModal from "@/components/ModalContent/RevokeTokenModal";
import { AccessToken } from "@prisma/client";
import useTokenStore from "@/store/tokens";
export default function Api() { export default function AccessTokens() {
const [newKeyModal, setNewKeyModal] = useState(false); const [newTokenModal, setNewTokenModal] = useState(false);
const [revokeTokenModal, setRevokeTokenModal] = useState(false);
const [selectedToken, setSelectedToken] = useState<AccessToken | null>(null);
const openRevokeModal = (token: AccessToken) => {
setSelectedToken(token);
setRevokeTokenModal(true);
};
const { setTokens, tokens } = useTokenStore();
useEffect(() => {
fetch("/api/v1/tokens")
.then((res) => res.json())
.then((data) => {
if (data.response) setTokens(data.response as AccessToken[]);
});
}, []);
return ( return (
<SettingsLayout> <SettingsLayout>
<p className="capitalize text-3xl font-thin inline">API Keys</p> <p className="capitalize text-3xl font-thin inline">Access Tokens</p>
<div className="divider my-3"></div> <div className="divider my-3"></div>
@ -20,16 +40,68 @@ export default function Api() {
<button <button
className={`btn btn-accent dark:border-violet-400 text-white tracking-wider w-fit flex items-center gap-2`} className={`btn btn-accent dark:border-violet-400 text-white tracking-wider w-fit flex items-center gap-2`}
onClick={() => { onClick={() => {
setNewKeyModal(true); setNewTokenModal(true);
}} }}
> >
New Access Token New Access Token
</button> </button>
{tokens.length > 0 ? (
<>
<div className="divider"></div>
<table className="table">
{/* head */}
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Created</th>
<th>Expires</th>
<th></th>
</tr>
</thead>
<tbody>
{tokens.map((token, i) => (
<React.Fragment key={i}>
<tr>
<th>{i + 1}</th>
<td>{token.name}</td>
<td>
{new Date(token.createdAt || "").toLocaleDateString()}
</td>
<td>
{new Date(token.expires || "").toLocaleDateString()}
</td>
<td>
<button
className="btn btn-sm btn-ghost btn-square hover:bg-red-500"
onClick={() => openRevokeModal(token as AccessToken)}
>
<i className="bi-x text-lg"></i>
</button>
</td>
</tr>
</React.Fragment>
))}
</tbody>
</table>
</>
) : undefined}
</div> </div>
{newKeyModal ? ( {newTokenModal ? (
<NewKeyModal onClose={() => setNewKeyModal(false)} /> <NewTokenModal onClose={() => setNewTokenModal(false)} />
) : undefined} ) : undefined}
{revokeTokenModal && selectedToken && (
<RevokeTokenModal
onClose={() => {
setRevokeTokenModal(false);
setSelectedToken(null);
}}
activeToken={selectedToken}
/>
)}
</SettingsLayout> </SettingsLayout>
); );
} }

View File

@ -0,0 +1,35 @@
/*
Warnings:
- You are about to drop the `ApiKey` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "ApiKey" DROP CONSTRAINT "ApiKey_userId_fkey";
-- DropTable
DROP TABLE "ApiKey";
-- CreateTable
CREATE TABLE "AccessToken" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"token" TEXT NOT NULL,
"revoked" BOOLEAN NOT NULL DEFAULT false,
"expires" TIMESTAMP(3) NOT NULL,
"lastUsedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AccessToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AccessToken_token_key" ON "AccessToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "AccessToken_name_userId_key" ON "AccessToken"("name", "userId");
-- AddForeignKey
ALTER TABLE "AccessToken" ADD CONSTRAINT "AccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX "AccessToken_name_userId_key";

View File

@ -39,7 +39,7 @@ model User {
pinnedLinks Link[] pinnedLinks Link[]
collectionsJoined UsersAndCollections[] collectionsJoined UsersAndCollections[]
whitelistedUsers WhitelistedUser[] whitelistedUsers WhitelistedUser[]
apiKeys ApiKey[] accessTokens AccessToken[]
subscriptions Subscription? subscriptions Subscription?
archiveAsScreenshot Boolean @default(true) archiveAsScreenshot Boolean @default(true)
archiveAsPDF Boolean @default(true) archiveAsPDF Boolean @default(true)
@ -142,16 +142,15 @@ model Subscription {
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
} }
model ApiKey { model AccessToken {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
userId Int userId Int
token String @unique token String @unique
revoked Boolean @default(false)
expires DateTime expires DateTime
lastUsedAt DateTime? lastUsedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
@@unique([name, userId])
} }

56
store/tokens.ts Normal file
View File

@ -0,0 +1,56 @@
import { AccessToken } from "@prisma/client";
import { create } from "zustand";
// Token store
type ResponseObject = {
ok: boolean;
data: object | string;
};
type TokenStore = {
tokens: Partial<AccessToken>[];
setTokens: (data: Partial<AccessToken>[]) => void;
addToken: (body: Partial<AccessToken>[]) => Promise<ResponseObject>;
revokeToken: (tokenId: number) => Promise<ResponseObject>;
};
const useTokenStore = create<TokenStore>((set) => ({
tokens: [],
setTokens: async (data) => {
set(() => ({
tokens: data,
}));
},
addToken: async (body) => {
const response = await fetch("/api/v1/tokens", {
body: JSON.stringify(body),
method: "POST",
});
const data = await response.json();
if (response.ok)
set((state) => ({
tokens: [...state.tokens, data.response.token],
}));
return { ok: response.ok, data: data.response };
},
revokeToken: async (tokenId) => {
const response = await fetch(`/api/v1/tokens/${tokenId}`, {
method: "DELETE",
});
const data = await response.json();
if (response.ok)
set((state) => ({
tokens: state.tokens.filter((token) => token.id !== tokenId),
}));
return { ok: response.ok, data: data.response };
},
}));
export default useTokenStore;

View File

@ -135,7 +135,7 @@ export enum LinkType {
image, image,
} }
export enum KeyExpiry { export enum TokenExpiry {
sevenDays, sevenDays,
oneMonth, oneMonth,
twoMonths, twoMonths,