diff --git a/components/ModalContent/NewCollectionModal.tsx b/components/ModalContent/NewCollectionModal.tsx index 7c17497..980bb44 100644 --- a/components/ModalContent/NewCollectionModal.tsx +++ b/components/ModalContent/NewCollectionModal.tsx @@ -47,7 +47,7 @@ export default function NewCollectionModal({ onClose, parent }: Props) { toast.dismiss(load); if (response.ok) { - toast.success(t("created")); + toast.success(t("created_success")); if (response.data) { setAccount(data?.user.id as number); onClose(); diff --git a/components/ModalContent/UploadFileModal.tsx b/components/ModalContent/UploadFileModal.tsx index 2f31f3a..ad4743f 100644 --- a/components/ModalContent/UploadFileModal.tsx +++ b/components/ModalContent/UploadFileModal.tsx @@ -120,7 +120,7 @@ export default function UploadFileModal({ onClose }: Props) { toast.dismiss(load); if (response.ok) { - toast.success(t("created")); + toast.success(t("created_success")); onClose(); } else { toast.error(response.data as string); diff --git a/lib/api/controllers/dashboard/getDashboardDataV2.ts b/lib/api/controllers/dashboard/getDashboardDataV2.ts new file mode 100644 index 0000000..6d2fb02 --- /dev/null +++ b/lib/api/controllers/dashboard/getDashboardDataV2.ts @@ -0,0 +1,119 @@ +import { prisma } from "@/lib/api/db"; +import { LinkRequestQuery, Sort } from "@/types/global"; + +type Response = + | { + data: D; + message: string; + status: number; + } + | { + data: D; + message: string; + status: number; + }; + +export default async function getDashboardData( + userId: number, + query: LinkRequestQuery +): Promise> { + let order: any; + if (query.sort === Sort.DateNewestFirst) order = { id: "desc" }; + else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" }; + else if (query.sort === Sort.NameAZ) order = { name: "asc" }; + else if (query.sort === Sort.NameZA) order = { name: "desc" }; + else if (query.sort === Sort.DescriptionAZ) order = { description: "asc" }; + else if (query.sort === Sort.DescriptionZA) order = { description: "desc" }; + + const numberOfPinnedLinks = await prisma.link.count({ + where: { + AND: [ + { + collection: { + OR: [ + { ownerId: userId }, + { + members: { + some: { userId }, + }, + }, + ], + }, + }, + { + pinnedBy: { some: { id: userId } }, + }, + ], + }, + }); + + const pinnedLinks = await prisma.link.findMany({ + take: 10, + where: { + AND: [ + { + collection: { + OR: [ + { ownerId: userId }, + { + members: { + some: { userId }, + }, + }, + ], + }, + }, + { + pinnedBy: { some: { id: userId } }, + }, + ], + }, + include: { + tags: true, + collection: true, + pinnedBy: { + where: { id: userId }, + select: { id: true }, + }, + }, + orderBy: order || { id: "desc" }, + }); + + const recentlyAddedLinks = await prisma.link.findMany({ + take: 10, + where: { + collection: { + OR: [ + { ownerId: userId }, + { + members: { + some: { userId }, + }, + }, + ], + }, + }, + include: { + tags: true, + collection: true, + pinnedBy: { + where: { id: userId }, + select: { id: true }, + }, + }, + orderBy: order || { id: "desc" }, + }); + + const links = [...recentlyAddedLinks, ...pinnedLinks].sort( + (a, b) => (new Date(b.id) as any) - (new Date(a.id) as any) + ); + + return { + data: { + links, + numberOfPinnedLinks, + }, + message: "Dashboard data fetched successfully.", + status: 200, + }; +} diff --git a/lib/api/controllers/session/createSession.ts b/lib/api/controllers/session/createSession.ts new file mode 100644 index 0000000..c2ff37e --- /dev/null +++ b/lib/api/controllers/session/createSession.ts @@ -0,0 +1,48 @@ +import { prisma } from "@/lib/api/db"; +import crypto from "crypto"; +import { decode, encode } from "next-auth/jwt"; + +export default async function createSession( + userId: number, + sessionName?: string +) { + const now = Date.now(); + const expiryDate = new Date(); + const oneDayInSeconds = 86400; + + expiryDate.setDate(expiryDate.getDate() + 73000); // 200 years (not really never) + const expiryDateSecond = 73050 * 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: sessionName || "Unknown Device", + userId, + token: tokenBody?.jti as string, + isSession: true, + expires: expiryDate, + }, + }); + + return { + response: { + token, + }, + status: 200, + }; +} diff --git a/lib/api/controllers/tokens/getTokens.ts b/lib/api/controllers/tokens/getTokens.ts index a5db351..1c6efe3 100644 --- a/lib/api/controllers/tokens/getTokens.ts +++ b/lib/api/controllers/tokens/getTokens.ts @@ -9,6 +9,7 @@ export default async function getToken(userId: number) { select: { id: true, name: true, + isSession: true, expires: true, createdAt: true, }, diff --git a/lib/api/verifyByCredentials.ts b/lib/api/verifyByCredentials.ts new file mode 100644 index 0000000..a0bbba2 --- /dev/null +++ b/lib/api/verifyByCredentials.ts @@ -0,0 +1,63 @@ +import { prisma } from "./db"; +import { User } from "@prisma/client"; +import verifySubscription from "./verifySubscription"; +import bcrypt from "bcrypt"; + +type Props = { + username: string; + password: string; +}; + +const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; +const emailEnabled = + process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false; + +export default async function verifyByCredentials({ + username, + password, +}: Props): Promise { + const user = await prisma.user.findFirst({ + where: emailEnabled + ? { + OR: [ + { + username: username.toLowerCase(), + }, + { + email: username?.toLowerCase(), + }, + ], + } + : { + username: username.toLowerCase(), + }, + include: { + subscriptions: true, + }, + }); + + if (!user) { + return null; + } + + let passwordMatches: boolean = false; + + if (user?.password) { + passwordMatches = bcrypt.compareSync(password, user.password); + + if (!passwordMatches) { + return null; + } else { + if (STRIPE_SECRET_KEY) { + const subscribedUser = await verifySubscription(user); + + if (!subscribedUser) { + return null; + } + } + return user; + } + } else { + return null; + } +} diff --git a/pages/api/v1/dashboard/index.ts b/pages/api/v1/dashboard/index.ts index ea2a3da..e4abcba 100644 --- a/pages/api/v1/dashboard/index.ts +++ b/pages/api/v1/dashboard/index.ts @@ -3,7 +3,10 @@ import { LinkRequestQuery } from "@/types/global"; import getDashboardData from "@/lib/api/controllers/dashboard/getDashboardData"; import verifyUser from "@/lib/api/verifyUser"; -export default async function links(req: NextApiRequest, res: NextApiResponse) { +export default async function dashboard( + req: NextApiRequest, + res: NextApiResponse +) { const user = await verifyUser({ req, res }); if (!user) return; diff --git a/pages/api/v1/session/index.ts b/pages/api/v1/session/index.ts new file mode 100644 index 0000000..bd3e50b --- /dev/null +++ b/pages/api/v1/session/index.ts @@ -0,0 +1,23 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import verifyByCredentials from "@/lib/api/verifyByCredentials"; +import createSession from "@/lib/api/controllers/session/createSession"; + +export default async function session( + req: NextApiRequest, + res: NextApiResponse +) { + const { username, password, sessionName } = req.body; + + const user = await verifyByCredentials({ username, password }); + + if (!user) + return res.status(400).json({ + response: + "Invalid credentials. You might need to reset your password if you're sure you already signed up with the current username/email.", + }); + + if (req.method === "POST") { + const token = await createSession(user.id, sessionName); + return res.status(token.status).json({ response: token.response }); + } +} diff --git a/pages/api/v2/dashboard/index.ts b/pages/api/v2/dashboard/index.ts new file mode 100644 index 0000000..2cb6c9e --- /dev/null +++ b/pages/api/v2/dashboard/index.ts @@ -0,0 +1,28 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { LinkRequestQuery } from "@/types/global"; +import getDashboardDataV2 from "@/lib/api/controllers/dashboard/getDashboardDataV2"; +import verifyUser from "@/lib/api/verifyUser"; + +export default async function dashboard( + req: NextApiRequest, + res: NextApiResponse +) { + const user = await verifyUser({ req, res }); + if (!user) return; + + if (req.method === "GET") { + const convertedData: LinkRequestQuery = { + sort: Number(req.query.sort as string), + cursor: req.query.cursor ? Number(req.query.cursor as string) : undefined, + }; + + const data = await getDashboardDataV2(user.id, convertedData); + return res.status(data.status).json({ + data: { + links: data.data.links, + numberOfPinnedLinks: data.data.numberOfPinnedLinks, + }, + message: data.message, + }); + } +} diff --git a/pages/settings/access-tokens.tsx b/pages/settings/access-tokens.tsx index ef66f6d..e526e2f 100644 --- a/pages/settings/access-tokens.tsx +++ b/pages/settings/access-tokens.tsx @@ -49,45 +49,50 @@ export default function AccessTokens() { {tokens.length > 0 ? ( - <> -
- - - - - - - - - - - - - {tokens.map((token, i) => ( - - - - - - - + + + + + + ))} + +
{t("name")}{t("created")}{t("expires")}
{i + 1}{token.name} - {new Date(token.createdAt || "").toLocaleDateString()} - - {new Date(token.expires || "").toLocaleDateString()} - - + {new Date(token.createdAt || "").toLocaleDateString()} + + {new Date(token.expires || "").toLocaleDateString()} + + +
) : undefined} diff --git a/prisma/migrations/20240626225737_add_new_field_to_access_tokens/migration.sql b/prisma/migrations/20240626225737_add_new_field_to_access_tokens/migration.sql new file mode 100644 index 0000000..f502cb9 --- /dev/null +++ b/prisma/migrations/20240626225737_add_new_field_to_access_tokens/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "AccessToken" ADD COLUMN "isSession" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5e81c39..c0ad94f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -178,6 +178,7 @@ model AccessToken { userId Int token String @unique revoked Boolean @default(false) + isSession Boolean @default(false) expires DateTime lastUsedAt DateTime? createdAt DateTime @default(now()) diff --git a/public/locales/en/common.json b/public/locales/en/common.json index d3ead13..379c8e4 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -92,7 +92,8 @@ "access_tokens_description": "Access Tokens can be used to access Linkwarden from other apps and services without giving away your Username and Password.", "new_token": "New Access Token", "name": "Name", - "created": "Created!", + "created_success": "Created!", + "created": "Created", "expires": "Expires", "accountSettings": "Account Settings", "language": "Language",