finalized adding support for access tokens
This commit is contained in:
parent
05563134b4
commit
5be194235c
|
@ -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>
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,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,
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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." });
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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([name, 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;
|
|
@ -135,7 +135,7 @@ export enum LinkType {
|
||||||
image,
|
image,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum KeyExpiry {
|
export enum TokenExpiry {
|
||||||
sevenDays,
|
sevenDays,
|
||||||
oneMonth,
|
oneMonth,
|
||||||
twoMonths,
|
twoMonths,
|
||||||
|
|
Ŝarĝante…
Reference in New Issue