From d91ebb3fa2aced31d256448786f5e88912b90f46 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Sat, 13 Jan 2024 01:20:06 -0500 Subject: [PATCH 001/109] added post key route --- components/ModalContent/NewKeyModal.tsx | 199 ++++++++++++++++++ components/SettingsSidebar.tsx | 6 +- lib/api/controllers/tokens/getTokens.ts | 22 ++ lib/api/controllers/tokens/postToken.ts | 77 +++++++ .../tokens/tokenId/deleteTokenById.ts | 16 ++ pages/api/v1/tokens/[id].ts | 13 ++ pages/api/v1/tokens/index.ts | 20 ++ pages/settings/access-tokens.tsx | 35 +++ pages/settings/api.tsx | 78 ------- .../migration.sql | 8 + .../20240113060555_minor_fix/migration.sql | 14 ++ prisma/schema.prisma | 4 +- types/global.ts | 8 + 13 files changed, 417 insertions(+), 83 deletions(-) create mode 100644 components/ModalContent/NewKeyModal.tsx create mode 100644 lib/api/controllers/tokens/getTokens.ts create mode 100644 lib/api/controllers/tokens/postToken.ts create mode 100644 lib/api/controllers/tokens/tokenId/deleteTokenById.ts create mode 100644 pages/api/v1/tokens/[id].ts create mode 100644 pages/api/v1/tokens/index.ts create mode 100644 pages/settings/access-tokens.tsx delete mode 100644 pages/settings/api.tsx create mode 100644 prisma/migrations/20240113051701_make_key_names_unique/migration.sql create mode 100644 prisma/migrations/20240113060555_minor_fix/migration.sql diff --git a/components/ModalContent/NewKeyModal.tsx b/components/ModalContent/NewKeyModal.tsx new file mode 100644 index 0000000..e36f724 --- /dev/null +++ b/components/ModalContent/NewKeyModal.tsx @@ -0,0 +1,199 @@ +import React, { useState } from "react"; +import TextInput from "@/components/TextInput"; +import { KeyExpiry } from "@/types/global"; +import { useSession } from "next-auth/react"; +import toast from "react-hot-toast"; +import Modal from "../Modal"; + +type Props = { + onClose: Function; +}; + +export default function NewKeyModal({ onClose }: Props) { + const { data } = useSession(); + + const initial = { + name: "", + expires: 0 as KeyExpiry, + }; + + const [key, setKey] = useState(initial as any); + + const [submitLoader, setSubmitLoader] = useState(false); + + const submit = async () => { + if (!submitLoader) { + setSubmitLoader(true); + + let response; + + const load = toast.loading("Creating..."); + + response = await fetch("/api/v1/tokens", { + method: "POST", + body: JSON.stringify({ + name: key.name, + expires: key.expires, + }), + }); + + const data = await response.json(); + + toast.dismiss(load); + + if (response.ok) { + toast.success(`Created!`); + onClose(); + } else toast.error(data.response as string); + + setSubmitLoader(false); + + return response; + } + }; + + return ( + +

Create an Access Token

+ +
+ +
+
+

Name

+ + setKey({ ...key, name: e.target.value })} + placeholder="e.g. For the Mobile App" + className="bg-base-200" + /> +
+ +
+

Date of Expiry

+ +
+
+ {key.expires === KeyExpiry.sevenDays && "7 Days"} + {key.expires === KeyExpiry.oneMonth && "30 Days"} + {key.expires === KeyExpiry.twoMonths && "60 Days"} + {key.expires === KeyExpiry.threeMonths && "90 Days"} + {key.expires === KeyExpiry.never && "No Expiration"} +
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+ +
+ +
+
+ ); +} diff --git a/components/SettingsSidebar.tsx b/components/SettingsSidebar.tsx index d4558f8..3ffbc5f 100644 --- a/components/SettingsSidebar.tsx +++ b/components/SettingsSidebar.tsx @@ -64,16 +64,16 @@ export default function SettingsSidebar({ className }: { className?: string }) { - +
-

API Keys

+

Access Token

diff --git a/lib/api/controllers/tokens/getTokens.ts b/lib/api/controllers/tokens/getTokens.ts new file mode 100644 index 0000000..d525ac7 --- /dev/null +++ b/lib/api/controllers/tokens/getTokens.ts @@ -0,0 +1,22 @@ +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) { + const getTokens = await prisma.apiKey.findMany({ + where: { + userId, + }, + select: { + name: true, + expires: true, + createdAt: true, + }, + }); + + return { + response: getTokens, + status: 200, + }; +} diff --git a/lib/api/controllers/tokens/postToken.ts b/lib/api/controllers/tokens/postToken.ts new file mode 100644 index 0000000..24a8cba --- /dev/null +++ b/lib/api/controllers/tokens/postToken.ts @@ -0,0 +1,77 @@ +import { prisma } from "@/lib/api/db"; +import { KeyExpiry } from "@/types/global"; +import bcrypt from "bcrypt"; +import crypto from "crypto"; + +export default async function postToken( + body: { + name: string; + expires: KeyExpiry; + }, + 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.apiKey.findFirst({ + where: { + name: body.name, + userId, + }, + }); + + if (checkIfTokenExists) { + return { + response: "Token with that name already exists.", + status: 400, + }; + } + + let expiryDate = new Date(); + + switch (body.expires) { + case KeyExpiry.sevenDays: + expiryDate.setDate(expiryDate.getDate() + 7); + break; + case KeyExpiry.oneMonth: + expiryDate.setDate(expiryDate.getDate() + 30); + break; + case KeyExpiry.twoMonths: + expiryDate.setDate(expiryDate.getDate() + 60); + break; + case KeyExpiry.threeMonths: + expiryDate.setDate(expiryDate.getDate() + 90); + break; + case KeyExpiry.never: + expiryDate.setDate(expiryDate.getDate() + 73000); // 200 years (not really never) + break; + default: + expiryDate.setDate(expiryDate.getDate() + 7); + break; + } + + const saltRounds = 10; + + const hashedKey = bcrypt.hashSync(crypto.randomUUID(), saltRounds); + + const createToken = await prisma.apiKey.create({ + data: { + name: body.name, + userId, + token: hashedKey, + expires: expiryDate, + }, + }); + + return { + response: createToken.token, + status: 200, + }; +} diff --git a/lib/api/controllers/tokens/tokenId/deleteTokenById.ts b/lib/api/controllers/tokens/tokenId/deleteTokenById.ts new file mode 100644 index 0000000..742016f --- /dev/null +++ b/lib/api/controllers/tokens/tokenId/deleteTokenById.ts @@ -0,0 +1,16 @@ +import { prisma } from "@/lib/api/db"; +import { KeyExpiry } from "@/types/global"; + +export default async function deleteToken(userId: number, tokenId: number) { + if (!tokenId) + return { response: "Please choose a valid token.", status: 401 }; + + const deletedToken = await prisma.apiKey.delete({ + where: { + id: tokenId, + userId, + }, + }); + + return { response: deletedToken, status: 200 }; +} 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/settings/access-tokens.tsx b/pages/settings/access-tokens.tsx new file mode 100644 index 0000000..14912a2 --- /dev/null +++ b/pages/settings/access-tokens.tsx @@ -0,0 +1,35 @@ +import SettingsLayout from "@/layouts/SettingsLayout"; +import React, { useState } from "react"; +import NewKeyModal from "@/components/ModalContent/NewKeyModal"; + +export default function Api() { + const [newKeyModal, setNewKeyModal] = useState(false); + + return ( + +

API Keys

+ +
+ +
+

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

+ + +
+ + {newKeyModal ? ( + setNewKeyModal(false)} /> + ) : undefined} +
+ ); +} 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/schema.prisma b/prisma/schema.prisma index 3f10539..60a03e1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -144,7 +144,7 @@ model Subscription { model ApiKey { id Int @id @default(autoincrement()) - name String + name String user User @relation(fields: [userId], references: [id]) userId Int token String @unique @@ -153,5 +153,5 @@ model ApiKey { createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt - @@unique([token, userId]) + @@unique([name, userId]) } diff --git a/types/global.ts b/types/global.ts index 8b3efcf..36e38c1 100644 --- a/types/global.ts +++ b/types/global.ts @@ -134,3 +134,11 @@ export enum LinkType { pdf, image, } + +export enum KeyExpiry { + sevenDays, + oneMonth, + twoMonths, + threeMonths, + never, +} From d4f59d7f324b44469519f733627bcdc0c26a9f96 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Sun, 14 Jan 2024 10:09:09 -0500 Subject: [PATCH 002/109] improvements to the pwa --- components/CollectionCard.tsx | 2 + components/FilterSearchDropdown.tsx | 2 + components/LinkViews/LinkCard.tsx | 9 +- .../LinkViews/LinkComponents/LinkActions.tsx | 2 + .../EditCollectionSharingModal.tsx | 2 + components/Navbar.tsx | 32 ++++-- components/NewButtonMobile.tsx | 97 +++++++++++++++++++ components/Sidebar.tsx | 2 +- components/SortDropdown.tsx | 2 + lib/client/utils.ts | 16 +++ pages/_app.tsx | 12 ++- pages/collections/[id].tsx | 2 + pages/collections/index.tsx | 1 - pages/dashboard.tsx | 2 + pages/search.tsx | 2 - pages/settings/account.tsx | 2 + pages/tags/[id].tsx | 2 + public/site.webmanifest | 2 +- 18 files changed, 168 insertions(+), 23 deletions(-) create mode 100644 components/NewButtonMobile.tsx create mode 100644 lib/client/utils.ts diff --git a/components/CollectionCard.tsx b/components/CollectionCard.tsx index d4c2571..5e1f8a4 100644 --- a/components/CollectionCard.tsx +++ b/components/CollectionCard.tsx @@ -9,6 +9,7 @@ import useAccountStore from "@/store/account"; import EditCollectionModal from "./ModalContent/EditCollectionModal"; import EditCollectionSharingModal from "./ModalContent/EditCollectionSharingModal"; import DeleteCollectionModal from "./ModalContent/DeleteCollectionModal"; +import { dropdownTriggerer } from "@/lib/client/utils"; type Props = { collection: CollectionIncludingMembersAndLinkCount; @@ -70,6 +71,7 @@ export default function CollectionCard({ collection, className }: Props) {
diff --git a/components/FilterSearchDropdown.tsx b/components/FilterSearchDropdown.tsx index 9ab2946..57a0ea4 100644 --- a/components/FilterSearchDropdown.tsx +++ b/components/FilterSearchDropdown.tsx @@ -1,3 +1,4 @@ +import { dropdownTriggerer } from "@/lib/client/utils"; import React from "react"; type Props = { @@ -20,6 +21,7 @@ export default function FilterSearchDropdown({
diff --git a/components/LinkViews/LinkCard.tsx b/components/LinkViews/LinkCard.tsx index 7854446..5282bd6 100644 --- a/components/LinkViews/LinkCard.tsx +++ b/components/LinkViews/LinkCard.tsx @@ -126,17 +126,12 @@ export default function LinkGrid({ link, count, className }: Props) { {unescapeString(link.name || link.description) || link.url}

- +

{shortendURL}

- +

diff --git a/components/LinkViews/LinkComponents/LinkActions.tsx b/components/LinkViews/LinkComponents/LinkActions.tsx index 2cd0b48..bddab64 100644 --- a/components/LinkViews/LinkComponents/LinkActions.tsx +++ b/components/LinkViews/LinkComponents/LinkActions.tsx @@ -10,6 +10,7 @@ import PreservedFormatsModal from "@/components/ModalContent/PreservedFormatsMod import useLinkStore from "@/store/links"; import { toast } from "react-hot-toast"; import useAccountStore from "@/store/account"; +import { dropdownTriggerer } from "@/lib/client/utils"; type Props = { link: LinkIncludingShortenedCollectionAndTags; @@ -71,6 +72,7 @@ export default function LinkActions({
diff --git a/components/ModalContent/EditCollectionSharingModal.tsx b/components/ModalContent/EditCollectionSharingModal.tsx index 7de480f..11690a7 100644 --- a/components/ModalContent/EditCollectionSharingModal.tsx +++ b/components/ModalContent/EditCollectionSharingModal.tsx @@ -9,6 +9,7 @@ import usePermissions from "@/hooks/usePermissions"; import ProfilePhoto from "../ProfilePhoto"; import addMemberToCollection from "@/lib/client/addMemberToCollection"; import Modal from "../Modal"; +import { dropdownTriggerer } from "@/lib/client/utils"; type Props = { onClose: Function; @@ -264,6 +265,7 @@ export default function EditCollectionSharingModal({
{roleLabel} diff --git a/components/Navbar.tsx b/components/Navbar.tsx index fb3678e..c0ca7f4 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -13,6 +13,8 @@ import NewLinkModal from "./ModalContent/NewLinkModal"; import NewCollectionModal from "./ModalContent/NewCollectionModal"; import Link from "next/link"; import UploadFileModal from "./ModalContent/UploadFileModal"; +import { dropdownTriggerer } from "@/lib/client/utils"; +import NewButtonMobile from "./NewButtonMobile"; export default function Navbar() { const { settings, updateSettings } = useLocalSettingsStore(); @@ -35,14 +37,12 @@ export default function Navbar() { useEffect(() => { setSidebar(false); - }, [width]); - - useEffect(() => { - setSidebar(false); - }, [router]); + document.body.style.overflow = "auto"; + }, [width, router]); const toggleSidebar = () => { - setSidebar(!sidebar); + setSidebar(false); + document.body.style.overflow = "auto"; }; const [newLinkModal, setNewLinkModal] = useState(false); @@ -52,7 +52,10 @@ export default function Navbar() { return (
{ + setSidebar(true); + document.body.style.overflow = "hidden"; + }} className="text-neutral btn btn-square btn-sm btn-ghost lg:hidden" > @@ -61,11 +64,12 @@ export default function Navbar() {
-
+
@@ -117,7 +121,12 @@ export default function Navbar() {
-
+
-
  • +
  • { (document?.activeElement as HTMLElement)?.blur(); @@ -161,6 +170,9 @@ export default function Navbar() {
  • + + + {sidebar ? (
    diff --git a/components/NewButtonMobile.tsx b/components/NewButtonMobile.tsx new file mode 100644 index 0000000..91988d3 --- /dev/null +++ b/components/NewButtonMobile.tsx @@ -0,0 +1,97 @@ +import { dropdownTriggerer } from "@/lib/client/utils"; +import React from "react"; +import { useEffect, useState } from "react"; +import NewLinkModal from "./ModalContent/NewLinkModal"; +import NewCollectionModal from "./ModalContent/NewCollectionModal"; +import UploadFileModal from "./ModalContent/UploadFileModal"; + +type Props = {}; + +export default function NewButtonMobile({}: Props) { + const [hasScrolled, setHasScrolled] = useState(false); + const [newLinkModal, setNewLinkModal] = useState(false); + const [newCollectionModal, setNewCollectionModal] = useState(false); + const [uploadFileModal, setUploadFileModal] = useState(false); + + useEffect(() => { + const handleScroll = () => { + if (window.scrollY > 0) { + setHasScrolled(true); + } else { + setHasScrolled(false); + } + }; + + window.addEventListener("scroll", handleScroll); + + return () => { + window.removeEventListener("scroll", handleScroll); + }; + }, []); + + return ( + <> +
    +
    + + + +
    +
      +
    • +
      { + (document?.activeElement as HTMLElement)?.blur(); + setNewLinkModal(true); + }} + tabIndex={0} + role="button" + > + New Link +
      +
    • + {/*
    • +
      { + (document?.activeElement as HTMLElement)?.blur(); + setUploadFileModal(true); + }} + tabIndex={0} + role="button" + > + Upload File +
      +
    • */} +
    • +
      { + (document?.activeElement as HTMLElement)?.blur(); + setNewCollectionModal(true); + }} + tabIndex={0} + role="button" + > + New Collection +
      +
    • +
    +
    + {newLinkModal ? ( + setNewLinkModal(false)} /> + ) : undefined} + {newCollectionModal ? ( + setNewCollectionModal(false)} /> + ) : undefined} + {uploadFileModal ? ( + setUploadFileModal(false)} /> + ) : undefined} + + ); +} diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 75c0686..e38a31d 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -44,7 +44,7 @@ export default function Sidebar({ className }: { className?: string }) { return (