commit
2901db7035
|
@ -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 (
|
||||||
|
<Modal toggleModal={onClose}>
|
||||||
|
{newToken ? (
|
||||||
|
<div className="flex flex-col justify-center space-y-4">
|
||||||
|
<p className="text-xl font-thin">Access Token Created</p>
|
||||||
|
<p>
|
||||||
|
Your new token has been created. Please copy it and store it
|
||||||
|
somewhere safe. You will not be able to see it again.
|
||||||
|
</p>
|
||||||
|
<TextInput
|
||||||
|
spellCheck={false}
|
||||||
|
value={newToken}
|
||||||
|
onChange={() => {}}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(newToken);
|
||||||
|
toast.success("Copied to clipboard!");
|
||||||
|
}}
|
||||||
|
className="btn btn-primary w-fit mx-auto"
|
||||||
|
>
|
||||||
|
Copy to Clipboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-xl font-thin">Create an Access Token</p>
|
||||||
|
|
||||||
|
<div className="divider mb-3 mt-1"></div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<div className="w-full">
|
||||||
|
<p className="mb-2">Name</p>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
value={token.name}
|
||||||
|
onChange={(e) => setToken({ ...token, name: e.target.value })}
|
||||||
|
placeholder="e.g. For the iOS shortcut"
|
||||||
|
className="bg-base-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2">Expires in</p>
|
||||||
|
|
||||||
|
<div className="dropdown dropdown-bottom dropdown-end">
|
||||||
|
<div
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
className="btn btn-outline w-36 flex items-center btn-sm h-10"
|
||||||
|
>
|
||||||
|
{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"}
|
||||||
|
</div>
|
||||||
|
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl w-52 mt-1">
|
||||||
|
<li>
|
||||||
|
<label
|
||||||
|
className="label cursor-pointer flex justify-start"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="sort-radio"
|
||||||
|
className="radio checked:bg-primary"
|
||||||
|
checked={token.expires === TokenExpiry.sevenDays}
|
||||||
|
onChange={() => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
setToken({
|
||||||
|
...token,
|
||||||
|
expires: TokenExpiry.sevenDays,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="label-text">7 Days</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<label
|
||||||
|
className="label cursor-pointer flex justify-start"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="sort-radio"
|
||||||
|
className="radio checked:bg-primary"
|
||||||
|
checked={token.expires === TokenExpiry.oneMonth}
|
||||||
|
onChange={() => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
setToken({ ...token, expires: TokenExpiry.oneMonth });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="label-text">30 Days</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<label
|
||||||
|
className="label cursor-pointer flex justify-start"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="sort-radio"
|
||||||
|
className="radio checked:bg-primary"
|
||||||
|
checked={token.expires === TokenExpiry.twoMonths}
|
||||||
|
onChange={() => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
setToken({
|
||||||
|
...token,
|
||||||
|
expires: TokenExpiry.twoMonths,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="label-text">60 Days</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<label
|
||||||
|
className="label cursor-pointer flex justify-start"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="sort-radio"
|
||||||
|
className="radio checked:bg-primary"
|
||||||
|
checked={token.expires === TokenExpiry.threeMonths}
|
||||||
|
onChange={() => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
setToken({
|
||||||
|
...token,
|
||||||
|
expires: TokenExpiry.threeMonths,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="label-text">90 Days</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<label
|
||||||
|
className="label cursor-pointer flex justify-start"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="sort-radio"
|
||||||
|
className="radio checked:bg-primary"
|
||||||
|
checked={token.expires === TokenExpiry.never}
|
||||||
|
onChange={() => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
setToken({ ...token, expires: TokenExpiry.never });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="label-text">No Expiration</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center mt-5">
|
||||||
|
<button
|
||||||
|
className="btn btn-accent dark:border-violet-400 text-white"
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
Create Access Token
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -64,16 +64,16 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/api">
|
<Link href="/settings/access-tokens">
|
||||||
<div
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
active === `/settings/api`
|
active === `/settings/access-tokens`
|
||||||
? "bg-primary/20"
|
? "bg-primary/20"
|
||||||
: "hover:bg-neutral/20"
|
: "hover:bg-neutral/20"
|
||||||
} 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">API Keys</p>
|
<p className="truncate w-full pr-7">Access Tokens</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ type Props = {
|
||||||
onChange: ChangeEventHandler<HTMLInputElement>;
|
onChange: ChangeEventHandler<HTMLInputElement>;
|
||||||
onKeyDown?: KeyboardEventHandler<HTMLInputElement> | undefined;
|
onKeyDown?: KeyboardEventHandler<HTMLInputElement> | undefined;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
spellCheck?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TextInput({
|
export default function TextInput({
|
||||||
|
@ -18,9 +19,11 @@ export default function TextInput({
|
||||||
onChange,
|
onChange,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
className,
|
className,
|
||||||
|
spellCheck,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
|
spellCheck={spellCheck}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
type={type ? type : "text"}
|
type={type ? type : "text"}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { prisma } from "@/lib/api/db";
|
||||||
|
|
||||||
|
export default async function getToken(userId: number) {
|
||||||
|
const getTokens = await prisma.accessToken.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
revoked: false,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
expires: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: getTokens,
|
||||||
|
status: 200,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { prisma } from "@/lib/api/db";
|
||||||
|
import { TokenExpiry } from "@/types/global";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { decode, encode } from "next-auth/jwt";
|
||||||
|
|
||||||
|
export default async function postToken(
|
||||||
|
body: {
|
||||||
|
name: string;
|
||||||
|
expires: TokenExpiry;
|
||||||
|
},
|
||||||
|
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.accessToken.findFirst({
|
||||||
|
where: {
|
||||||
|
name: body.name,
|
||||||
|
revoked: false,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (checkIfTokenExists) {
|
||||||
|
return {
|
||||||
|
response: "Token with that name already exists.",
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
let expiryDate = new Date();
|
||||||
|
const oneDayInSeconds = 86400;
|
||||||
|
let expiryDateSecond = 7 * oneDayInSeconds;
|
||||||
|
|
||||||
|
if (body.expires === TokenExpiry.oneMonth) {
|
||||||
|
expiryDate.setDate(expiryDate.getDate() + 30);
|
||||||
|
expiryDateSecond = 30 * oneDayInSeconds;
|
||||||
|
} else if (body.expires === TokenExpiry.twoMonths) {
|
||||||
|
expiryDate.setDate(expiryDate.getDate() + 60);
|
||||||
|
expiryDateSecond = 60 * oneDayInSeconds;
|
||||||
|
} else if (body.expires === TokenExpiry.threeMonths) {
|
||||||
|
expiryDate.setDate(expiryDate.getDate() + 90);
|
||||||
|
expiryDateSecond = 90 * oneDayInSeconds;
|
||||||
|
} else if (body.expires === TokenExpiry.never) {
|
||||||
|
expiryDate.setDate(expiryDate.getDate() + 73000); // 200 years (not really never)
|
||||||
|
expiryDateSecond = 73050 * oneDayInSeconds;
|
||||||
|
} else {
|
||||||
|
expiryDate.setDate(expiryDate.getDate() + 7);
|
||||||
|
expiryDateSecond = 7 * oneDayInSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await encode({
|
||||||
|
token: {
|
||||||
|
id: userId,
|
||||||
|
iat: now / 1000,
|
||||||
|
exp: (expiryDate as any) / 1000,
|
||||||
|
jti: crypto.randomUUID(),
|
||||||
|
},
|
||||||
|
maxAge: expiryDateSecond || 604800,
|
||||||
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenBody = await decode({
|
||||||
|
token,
|
||||||
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createToken = await prisma.accessToken.create({
|
||||||
|
data: {
|
||||||
|
name: body.name,
|
||||||
|
userId,
|
||||||
|
token: tokenBody?.jti as string,
|
||||||
|
expires: expiryDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: {
|
||||||
|
secretKey: token,
|
||||||
|
token: createToken,
|
||||||
|
},
|
||||||
|
status: 200,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { prisma } from "@/lib/api/db";
|
||||||
|
|
||||||
|
export default async function deleteToken(userId: number, tokenId: number) {
|
||||||
|
if (!tokenId)
|
||||||
|
return { response: "Please choose a valid token.", status: 401 };
|
||||||
|
|
||||||
|
const tokenExists = await prisma.accessToken.findFirst({
|
||||||
|
where: {
|
||||||
|
id: tokenId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const revokedToken = await prisma.accessToken.update({
|
||||||
|
where: {
|
||||||
|
id: tokenExists?.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
revoked: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { response: revokedToken, status: 200 };
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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,14 +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 });
|
||||||
const userId = token?.id;
|
|
||||||
|
|
||||||
if (!userId) {
|
if (typeof token === "string") {
|
||||||
res.status(401).json({ response: "You must be logged in." });
|
res.status(401).json({ response: token });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userId = token?.id;
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: userId,
|
id: userId,
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -65,6 +65,7 @@ import ZitadelProvider from "next-auth/providers/zitadel";
|
||||||
import ZohoProvider from "next-auth/providers/zoho";
|
import ZohoProvider from "next-auth/providers/zoho";
|
||||||
import ZoomProvider from "next-auth/providers/zoom";
|
import ZoomProvider from "next-auth/providers/zoom";
|
||||||
import * as process from "process";
|
import * as process from "process";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
const emailEnabled =
|
const emailEnabled =
|
||||||
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
|
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
|
||||||
|
@ -1059,7 +1060,8 @@ if (process.env.NEXT_PUBLIC_ZOOM_ENABLED_ENABLED === "true") {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authOptions: AuthOptions = {
|
export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
return await NextAuth(req, res, {
|
||||||
adapter: adapter as Adapter,
|
adapter: adapter as Adapter,
|
||||||
session: {
|
session: {
|
||||||
strategy: "jwt",
|
strategy: "jwt",
|
||||||
|
@ -1113,6 +1115,5 @@ export const authOptions: AuthOptions = {
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
}
|
||||||
export default NextAuth(authOptions);
|
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,20 +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 });
|
||||||
const userId = token?.id;
|
|
||||||
|
|
||||||
if (!userId) {
|
if (typeof token === "string") {
|
||||||
return res.status(401).json({ response: "You must be logged in." });
|
res.status(401).json({ response: token });
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userId = token?.id;
|
||||||
|
|
||||||
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." });
|
||||||
|
|
||||||
|
|
|
@ -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<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 (
|
||||||
|
<SettingsLayout>
|
||||||
|
<p className="capitalize text-3xl font-thin inline">Access Tokens</p>
|
||||||
|
|
||||||
|
<div className="divider my-3"></div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p>
|
||||||
|
Access Tokens can be used to access Linkwarden from other apps and
|
||||||
|
services without giving away your Username and Password.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`btn btn-accent dark:border-violet-400 text-white tracking-wider w-fit flex items-center gap-2`}
|
||||||
|
onClick={() => {
|
||||||
|
setNewTokenModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
New Access Token
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{newTokenModal ? (
|
||||||
|
<NewTokenModal onClose={() => setNewTokenModal(false)} />
|
||||||
|
) : undefined}
|
||||||
|
{revokeTokenModal && selectedToken && (
|
||||||
|
<RevokeTokenModal
|
||||||
|
onClose={() => {
|
||||||
|
setRevokeTokenModal(false);
|
||||||
|
setSelectedToken(null);
|
||||||
|
}}
|
||||||
|
activeToken={selectedToken}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingsLayout>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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<AccountSettings>(account);
|
|
||||||
|
|
||||||
const [archiveAsScreenshot, setArchiveAsScreenshot] =
|
|
||||||
useState<boolean>(false);
|
|
||||||
const [archiveAsPDF, setArchiveAsPDF] = useState<boolean>(false);
|
|
||||||
const [archiveAsWaybackMachine, setArchiveAsWaybackMachine] =
|
|
||||||
useState<boolean>(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 (
|
|
||||||
<SettingsLayout>
|
|
||||||
<p className="capitalize text-3xl font-thin inline">API Keys (Soon)</p>
|
|
||||||
|
|
||||||
<div className="divider my-3"></div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="badge badge-warning rounded-md w-fit">
|
|
||||||
Status: Under Development
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>This page will be for creating and managing your API keys.</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
For now, you can <i>temporarily</i> use your{" "}
|
|
||||||
<code className="text-xs whitespace-nowrap bg-black/40 rounded-md px-2 py-1">
|
|
||||||
next-auth.session-token
|
|
||||||
</code>{" "}
|
|
||||||
in your browser cookies as the API key for your integrations.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</SettingsLayout>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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");
|
|
@ -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");
|
|
@ -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;
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "AccessToken_name_userId_key";
|
|
@ -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([token, userId])
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
@ -134,3 +134,11 @@ export enum LinkType {
|
||||||
pdf,
|
pdf,
|
||||||
image,
|
image,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum TokenExpiry {
|
||||||
|
sevenDays,
|
||||||
|
oneMonth,
|
||||||
|
twoMonths,
|
||||||
|
threeMonths,
|
||||||
|
never,
|
||||||
|
}
|
||||||
|
|
Ŝarĝante…
Reference in New Issue