diff --git a/components/ModalContent/NewTokenModal.tsx b/components/ModalContent/NewTokenModal.tsx new file mode 100644 index 0000000..15887d8 --- /dev/null +++ b/components/ModalContent/NewTokenModal.tsx @@ -0,0 +1,225 @@ +import React, { useState } from "react"; +import TextInput from "@/components/TextInput"; +import { TokenExpiry } from "@/types/global"; +import toast from "react-hot-toast"; +import Modal from "../Modal"; +import useTokenStore from "@/store/tokens"; + +type Props = { + onClose: Function; +}; + +export default function NewTokenModal({ onClose }: Props) { + const [newToken, setNewToken] = useState(""); + + const { addToken } = useTokenStore(); + + const initial = { + name: "", + expires: 0 as TokenExpiry, + }; + + const [token, setToken] = useState(initial as any); + + const [submitLoader, setSubmitLoader] = useState(false); + + const submit = async () => { + if (!submitLoader) { + setSubmitLoader(true); + + const load = toast.loading("Creating..."); + + const { ok, data } = await addToken(token); + + toast.dismiss(load); + + if (ok) { + toast.success(`Created!`); + setNewToken((data as any).secretKey); + } else toast.error(data as string); + + setSubmitLoader(false); + } + }; + + return ( + + {newToken ? ( +
+

Access Token Created

+

+ Your new token has been created. Please copy it and store it + somewhere safe. You will not be able to see it again. +

+ {}} + className="w-full" + /> + +
+ ) : ( + <> +

Create an Access Token

+ +
+ +
+
+

Name

+ + setToken({ ...token, name: e.target.value })} + placeholder="e.g. For the iOS shortcut" + className="bg-base-200" + /> +
+ +
+

Expires in

+ +
+
+ {token.expires === TokenExpiry.sevenDays && "7 Days"} + {token.expires === TokenExpiry.oneMonth && "30 Days"} + {token.expires === TokenExpiry.twoMonths && "60 Days"} + {token.expires === TokenExpiry.threeMonths && "90 Days"} + {token.expires === TokenExpiry.never && "No Expiration"} +
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+ +
+ +
+ + )} +
+ ); +} diff --git a/components/ModalContent/RevokeTokenModal.tsx b/components/ModalContent/RevokeTokenModal.tsx new file mode 100644 index 0000000..9aef2d9 --- /dev/null +++ b/components/ModalContent/RevokeTokenModal.tsx @@ -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(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 ( + +

Revoke Token

+ +
+ +
+

+ 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. +

+ + +
+
+ ); +} diff --git a/components/SettingsSidebar.tsx b/components/SettingsSidebar.tsx index d4558f8..bcd1644 100644 --- a/components/SettingsSidebar.tsx +++ b/components/SettingsSidebar.tsx @@ -64,16 +64,16 @@ export default function SettingsSidebar({ className }: { className?: string }) { - +
-

API Keys

+

Access Tokens

diff --git a/components/TextInput.tsx b/components/TextInput.tsx index 146a15c..c8099d0 100644 --- a/components/TextInput.tsx +++ b/components/TextInput.tsx @@ -8,6 +8,7 @@ type Props = { onChange: ChangeEventHandler; onKeyDown?: KeyboardEventHandler | undefined; className?: string; + spellCheck?: boolean; }; export default function TextInput({ @@ -18,9 +19,11 @@ export default function TextInput({ onChange, onKeyDown, className, + spellCheck, }: Props) { return ( { + 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; +} diff --git a/lib/api/verifyUser.ts b/lib/api/verifyUser.ts index db59e6e..847bdaf 100644 --- a/lib/api/verifyUser.ts +++ b/lib/api/verifyUser.ts @@ -1,8 +1,8 @@ import { NextApiRequest, NextApiResponse } from "next"; -import { getToken } from "next-auth/jwt"; import { prisma } from "./db"; import { User } from "@prisma/client"; import verifySubscription from "./verifySubscription"; +import verifyToken from "./verifyToken"; type Props = { req: NextApiRequest; @@ -15,14 +15,15 @@ export default async function verifyUser({ req, res, }: Props): Promise { - const token = await getToken({ req }); - const userId = token?.id; + const token = await verifyToken({ req }); - if (!userId) { - res.status(401).json({ response: "You must be logged in." }); + if (typeof token === "string") { + res.status(401).json({ response: token }); return null; } + const userId = token?.id; + const user = await prisma.user.findUnique({ where: { id: userId, diff --git a/pages/api/v1/archives/[linkId].ts b/pages/api/v1/archives/[linkId].ts index 3734692..b13e690 100644 --- a/pages/api/v1/archives/[linkId].ts +++ b/pages/api/v1/archives/[linkId].ts @@ -1,6 +1,5 @@ import type { NextApiRequest, NextApiResponse } from "next"; import readFile from "@/lib/api/storage/readFile"; -import { getToken } from "next-auth/jwt"; import { prisma } from "@/lib/api/db"; import { ArchivedFormat } from "@/types/global"; import verifyUser from "@/lib/api/verifyUser"; @@ -9,6 +8,7 @@ import { UsersAndCollections } from "@prisma/client"; import formidable from "formidable"; import createFile from "@/lib/api/storage/createFile"; import fs from "fs"; +import verifyToken from "@/lib/api/verifyToken"; export const config = { api: { @@ -33,8 +33,8 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) { return res.status(401).json({ response: "Invalid parameters." }); if (req.method === "GET") { - const token = await getToken({ req }); - const userId = token?.id; + const token = await verifyToken({ req }); + const userId = typeof token === "string" ? undefined : token?.id; const collectionIsAccessible = await prisma.collection.findFirst({ where: { diff --git a/pages/api/v1/auth/[...nextauth].ts b/pages/api/v1/auth/[...nextauth].ts index d7549de..c72d60d 100644 --- a/pages/api/v1/auth/[...nextauth].ts +++ b/pages/api/v1/auth/[...nextauth].ts @@ -65,6 +65,7 @@ import ZitadelProvider from "next-auth/providers/zitadel"; import ZohoProvider from "next-auth/providers/zoho"; import ZoomProvider from "next-auth/providers/zoom"; import * as process from "process"; +import type { NextApiRequest, NextApiResponse } from "next"; const emailEnabled = process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false; @@ -1059,60 +1060,60 @@ if (process.env.NEXT_PUBLIC_ZOOM_ENABLED_ENABLED === "true") { }; } -export const authOptions: AuthOptions = { - adapter: adapter as Adapter, - session: { - strategy: "jwt", - maxAge: 30 * 24 * 60 * 60, // 30 days - }, - providers, - pages: { - signIn: "/login", - verifyRequest: "/confirmation", - }, - callbacks: { - async signIn({ user, account, profile, email, credentials }) { - if (account?.provider !== "credentials") { - // registration via SSO can be separately disabled - const existingUser = await prisma.account.findFirst({ - where: { - providerAccountId: account?.providerAccountId, - }, - }); - if (existingUser && newSsoUsersDisabled) { - return false; +export default async function auth(req: NextApiRequest, res: NextApiResponse) { + return await NextAuth(req, res, { + adapter: adapter as Adapter, + session: { + strategy: "jwt", + maxAge: 30 * 24 * 60 * 60, // 30 days + }, + providers, + pages: { + signIn: "/login", + verifyRequest: "/confirmation", + }, + callbacks: { + async signIn({ user, account, profile, email, credentials }) { + if (account?.provider !== "credentials") { + // registration via SSO can be separately disabled + const existingUser = await prisma.account.findFirst({ + where: { + providerAccountId: account?.providerAccountId, + }, + }); + if (existingUser && newSsoUsersDisabled) { + return false; + } } - } - return true; - }, - async jwt({ token, trigger, user }) { - token.sub = token.sub ? Number(token.sub) : undefined; - if (trigger === "signIn" || trigger === "signUp") - token.id = user?.id as number; + return true; + }, + async jwt({ token, trigger, user }) { + token.sub = token.sub ? Number(token.sub) : undefined; + if (trigger === "signIn" || trigger === "signUp") + token.id = user?.id as number; - return token; - }, - async session({ session, token }) { - session.user.id = token.id; + return token; + }, + async session({ session, token }) { + session.user.id = token.id; - if (STRIPE_SECRET_KEY) { - const user = await prisma.user.findUnique({ - where: { - id: token.id, - }, - include: { - subscriptions: true, - }, - }); + if (STRIPE_SECRET_KEY) { + const user = await prisma.user.findUnique({ + where: { + id: token.id, + }, + include: { + subscriptions: true, + }, + }); - if (user) { - const subscribedUser = await verifySubscription(user); + if (user) { + const subscribedUser = await verifySubscription(user); + } } - } - return session; + return session; + }, }, - }, -}; - -export default NextAuth(authOptions); + }); +} diff --git a/pages/api/v1/avatar/[id].ts b/pages/api/v1/avatar/[id].ts index 8c591ff..c39f420 100644 --- a/pages/api/v1/avatar/[id].ts +++ b/pages/api/v1/avatar/[id].ts @@ -1,7 +1,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { prisma } from "@/lib/api/db"; 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) { const queryId = Number(req.query.id); @@ -12,8 +12,8 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) { .status(401) .send("Invalid parameters."); - const token = await getToken({ req }); - const userId = token?.id; + const token = await verifyToken({ req }); + const userId = typeof token === "string" ? undefined : token?.id; if (req.method === "GET") { const targetUser = await prisma.user.findUnique({ diff --git a/pages/api/v1/public/users/[id].ts b/pages/api/v1/public/users/[id].ts index f5b66ed..f34671d 100644 --- a/pages/api/v1/public/users/[id].ts +++ b/pages/api/v1/public/users/[id].ts @@ -1,10 +1,10 @@ import type { NextApiRequest, NextApiResponse } from "next"; 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) { - const token = await getToken({ req }); - const requestingId = token?.id; + const token = await verifyToken({ req }); + const requestingId = typeof token === "string" ? undefined : token?.id; const lookupId = req.query.id as string; 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/api/v1/users/[id].ts b/pages/api/v1/users/[id].ts index 016ef9a..cf75e16 100644 --- a/pages/api/v1/users/[id].ts +++ b/pages/api/v1/users/[id].ts @@ -2,20 +2,22 @@ import type { NextApiRequest, NextApiResponse } from "next"; import getUserById from "@/lib/api/controllers/users/userId/getUserById"; import updateUserById from "@/lib/api/controllers/users/userId/updateUserById"; import deleteUserById from "@/lib/api/controllers/users/userId/deleteUserById"; -import { getToken } from "next-auth/jwt"; import { prisma } from "@/lib/api/db"; import verifySubscription from "@/lib/api/verifySubscription"; +import verifyToken from "@/lib/api/verifyToken"; const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; export default async function users(req: NextApiRequest, res: NextApiResponse) { - const token = await getToken({ req }); - const userId = token?.id; + const token = await verifyToken({ req }); - if (!userId) { - return res.status(401).json({ response: "You must be logged in." }); + if (typeof token === "string") { + res.status(401).json({ response: token }); + return null; } + const userId = token?.id; + if (userId !== Number(req.query.id)) return res.status(401).json({ response: "Permission denied." }); diff --git a/pages/settings/access-tokens.tsx b/pages/settings/access-tokens.tsx new file mode 100644 index 0000000..f43a459 --- /dev/null +++ b/pages/settings/access-tokens.tsx @@ -0,0 +1,107 @@ +import SettingsLayout from "@/layouts/SettingsLayout"; +import React, { useEffect, useState } from "react"; +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 AccessTokens() { + const [newTokenModal, setNewTokenModal] = useState(false); + const [revokeTokenModal, setRevokeTokenModal] = useState(false); + const [selectedToken, setSelectedToken] = useState(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 ( + +

Access Tokens

+ +
+ +
+

+ Access Tokens can be used to access Linkwarden from other apps and + services without giving away your Username and Password. +

+ + + + {tokens.length > 0 ? ( + <> +
+ + + {/* head */} + + + + + + + + + + + {tokens.map((token, i) => ( + + + + + + + + + + ))} + +
NameCreatedExpires
{i + 1}{token.name} + {new Date(token.createdAt || "").toLocaleDateString()} + + {new Date(token.expires || "").toLocaleDateString()} + + +
+ + ) : undefined} +
+ + {newTokenModal ? ( + setNewTokenModal(false)} /> + ) : undefined} + {revokeTokenModal && selectedToken && ( + { + setRevokeTokenModal(false); + setSelectedToken(null); + }} + activeToken={selectedToken} + /> + )} +
+ ); +} 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/migrations/20240124192212_added_revoke_field/migration.sql b/prisma/migrations/20240124192212_added_revoke_field/migration.sql new file mode 100644 index 0000000..b9802a0 --- /dev/null +++ b/prisma/migrations/20240124192212_added_revoke_field/migration.sql @@ -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; diff --git a/prisma/migrations/20240124201018_removed_name_unique_constraint/migration.sql b/prisma/migrations/20240124201018_removed_name_unique_constraint/migration.sql new file mode 100644 index 0000000..4a570db --- /dev/null +++ b/prisma/migrations/20240124201018_removed_name_unique_constraint/migration.sql @@ -0,0 +1,2 @@ +-- DropIndex +DROP INDEX "AccessToken_name_userId_key"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3f10539..aebc98f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -39,7 +39,7 @@ model User { pinnedLinks Link[] collectionsJoined UsersAndCollections[] whitelistedUsers WhitelistedUser[] - apiKeys ApiKey[] + accessTokens AccessToken[] subscriptions Subscription? archiveAsScreenshot Boolean @default(true) archiveAsPDF Boolean @default(true) @@ -142,16 +142,15 @@ model Subscription { updatedAt DateTime @default(now()) @updatedAt } -model ApiKey { +model AccessToken { id Int @id @default(autoincrement()) - name String + name String user User @relation(fields: [userId], references: [id]) userId Int token String @unique + revoked Boolean @default(false) expires DateTime lastUsedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt - - @@unique([token, userId]) } diff --git a/store/tokens.ts b/store/tokens.ts new file mode 100644 index 0000000..eff1100 --- /dev/null +++ b/store/tokens.ts @@ -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[]; + setTokens: (data: Partial[]) => void; + addToken: (body: Partial[]) => Promise; + revokeToken: (tokenId: number) => Promise; +}; + +const useTokenStore = create((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; diff --git a/types/global.ts b/types/global.ts index 8b3efcf..3c8de79 100644 --- a/types/global.ts +++ b/types/global.ts @@ -134,3 +134,11 @@ export enum LinkType { pdf, image, } + +export enum TokenExpiry { + sevenDays, + oneMonth, + twoMonths, + threeMonths, + never, +}