Merge pull request #441 from linkwarden/feat/api-keys

Feat/api keys
This commit is contained in:
Daniel
2024-01-25 00:19:17 +03:30
committed by GitHub
25 changed files with 806 additions and 155 deletions
+3 -3
View File
@@ -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: {
+51 -50
View File
@@ -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);
});
}
+3 -3
View File
@@ -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({
+3 -3
View File
@@ -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;
+13
View File
@@ -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 });
}
}
+20
View File
@@ -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 });
}
}
+7 -5
View File
@@ -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." });
+107
View File
@@ -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>
);
}
-78
View File
@@ -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>
);
}