diff --git a/components/Modal/Collection/TeamManagement.tsx b/components/Modal/Collection/TeamManagement.tsx index 0dfa92e..b625570 100644 --- a/components/Modal/Collection/TeamManagement.tsx +++ b/components/Modal/Collection/TeamManagement.tsx @@ -47,6 +47,7 @@ export default function TeamManagement({ id: null, name: "", username: "", + image: "", }); useEffect(() => { @@ -401,7 +402,7 @@ export default function TeamManagement({ >
diff --git a/components/SettingsSidebar.tsx b/components/SettingsSidebar.tsx index d5b072b..ac9a7b7 100644 --- a/components/SettingsSidebar.tsx +++ b/components/SettingsSidebar.tsx @@ -113,7 +113,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
- {process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE ? ( + {process.env.NEXT_PUBLIC_STRIPE ? (
{ - if ( - status === "authenticated" && - (!process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE || data.user.isSubscriber) - ) { + if (status === "authenticated") { + setAccount(data?.user.id as number); + } + }, [status, data]); + + // Get the rest of the data + useEffect(() => { + if (account.id && (!process.env.NEXT_PUBLIC_STRIPE || account.username)) { setCollections(); setTags(); // setLinks(); - setAccount(data.user.id); } - }, [status, data]); + }, [account]); } diff --git a/hooks/useRedirect.tsx b/hooks/useRedirect.tsx deleted file mode 100644 index a6b9637..0000000 --- a/hooks/useRedirect.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useEffect, useState } from "react"; -import { useSession } from "next-auth/react"; -import { useRouter } from "next/router"; - -export default function useRedirect() { - const router = useRouter(); - const { status } = useSession(); - const [redirect, setRedirect] = useState(true); - - useEffect(() => { - if ( - status === "authenticated" && - (router.pathname === "/login" || router.pathname === "/register") - ) { - router.push("/").then(() => { - setRedirect(false); - }); - } else if ( - status === "unauthenticated" && - !(router.pathname === "/login" || router.pathname === "/register") - ) { - router.push("/login").then(() => { - setRedirect(false); - }); - } else if (status === "loading") setRedirect(true); - else setRedirect(false); - }, [status]); - - return redirect; -} diff --git a/layouts/AuthRedirect.tsx b/layouts/AuthRedirect.tsx index 8942666..da1f6fc 100644 --- a/layouts/AuthRedirect.tsx +++ b/layouts/AuthRedirect.tsx @@ -16,17 +16,29 @@ export default function AuthRedirect({ children }: Props) { const [redirect, setRedirect] = useState(true); const { account } = useAccountStore(); - const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; + const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true"; + const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true"; useInitialData(); useEffect(() => { if (!router.pathname.startsWith("/public")) { if ( + status === "authenticated" && + account.id && + !account.subscription?.active && + stripeEnabled + ) { + router.push("/subscribe").then(() => { + setRedirect(false); + }); + } + // Redirect to "/choose-username" if user is authenticated and is either a subscriber OR subscription is undefiend, and doesn't have a username + else if ( emailEnabled && status === "authenticated" && - (data.user.isSubscriber === true || - data.user.isSubscriber === undefined) && + account.subscription?.active && + stripeEnabled && account.id && !account.username ) { @@ -35,21 +47,16 @@ export default function AuthRedirect({ children }: Props) { }); } else if ( status === "authenticated" && - data.user.isSubscriber === false - ) { - router.push("/subscribe").then(() => { - setRedirect(false); - }); - } else if ( - status === "authenticated" && + account.id && (router.pathname === "/login" || router.pathname === "/register" || router.pathname === "/confirmation" || router.pathname === "/subscribe" || router.pathname === "/choose-username" || - router.pathname === "/forgot") + router.pathname === "/forgot" || + router.pathname === "/") ) { - router.push("/").then(() => { + router.push("/dashboard").then(() => { setRedirect(false); }); } else if ( @@ -69,7 +76,7 @@ export default function AuthRedirect({ children }: Props) { } else { setRedirect(false); } - }, [status, account]); + }, [status, account, router.pathname]); if (status !== "loading" && !redirect) return <>{children}; else return <>; diff --git a/layouts/CenteredForm.tsx b/layouts/CenteredForm.tsx index 03052ec..83ac99a 100644 --- a/layouts/CenteredForm.tsx +++ b/layouts/CenteredForm.tsx @@ -10,10 +10,20 @@ interface Props { export default function CenteredForm({ text, children }: Props) { const { theme } = useTheme(); + return (
- {theme === "dark" ? ( + {theme ? ( + Linkwarden + ) : undefined} + {/* {theme === "dark" ? ( Linkwarden - )} + )} */} {text ? (

{text} diff --git a/layouts/LinkLayout.tsx b/layouts/LinkLayout.tsx index 4850802..b2c135e 100644 --- a/layouts/LinkLayout.tsx +++ b/layouts/LinkLayout.tsx @@ -93,11 +93,14 @@ export default function LinkLayout({ children }: Props) {

*/}
router.back()} + onClick={() => router.push(`/collections/${linkCollection?.id}`)} className="inline-flex gap-1 hover:opacity-60 items-center select-none cursor-pointer p-2 lg:p-0 lg:px-1 lg:my-2 text-gray-500 dark:text-gray-300 rounded-md duration-100" > - Back + Back{" "} + + to {linkCollection?.name} +
diff --git a/lib/api/authenticateUser.ts b/lib/api/authenticateUser.ts deleted file mode 100644 index 12f744f..0000000 --- a/lib/api/authenticateUser.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { NextApiRequest, NextApiResponse } from "next"; -import { getToken } from "next-auth/jwt"; -import { prisma } from "./db"; -import { User } from "@prisma/client"; - -type Props = { - req: NextApiRequest; - res: NextApiResponse; -}; - -export default async function authenticateUser({ - req, - res, -}: Props): Promise { - const token = await getToken({ req }); - const userId = token?.id; - - if (!userId) { - res.status(401).json({ message: "You must be logged in." }); - return null; - } else if (process.env.STRIPE_SECRET_KEY && token.isSubscriber === false) { - res.status(401).json({ - message: - "You are not a subscriber, feel free to reach out to us at support@linkwarden.app if you think this is an issue.", - }); - return null; - } - - const user = await prisma.user.findUnique({ where: { id: userId } }); - return user; -} diff --git a/lib/api/checkSubscription.ts b/lib/api/checkSubscription.ts deleted file mode 100644 index b459fb3..0000000 --- a/lib/api/checkSubscription.ts +++ /dev/null @@ -1,49 +0,0 @@ -import Stripe from "stripe"; - -export default async function checkSubscription( - stripeSecretKey: string, - email: string -) { - const stripe = new Stripe(stripeSecretKey, { - apiVersion: "2022-11-15", - }); - - const listByEmail = await stripe.customers.list({ - email: email.toLowerCase(), - expand: ["data.subscriptions"], - }); - - let subscriptionCanceledAt: number | null | undefined; - - const isSubscriber = listByEmail.data.some((customer, i) => { - const hasValidSubscription = customer.subscriptions?.data.some( - (subscription) => { - const NEXT_PUBLIC_TRIAL_PERIOD_DAYS = - process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS; - const secondsInTwoWeeks = NEXT_PUBLIC_TRIAL_PERIOD_DAYS - ? Number(NEXT_PUBLIC_TRIAL_PERIOD_DAYS) * 86400 - : 1209600; - - subscriptionCanceledAt = subscription.canceled_at; - - const isNotCanceledOrHasTime = !( - subscription.canceled_at && - new Date() > - new Date((subscription.canceled_at + secondsInTwoWeeks) * 1000) - ); - - return subscription?.items?.data[0].plan && isNotCanceledOrHasTime; - } - ); - - return ( - customer.email?.toLowerCase() === email.toLowerCase() && - hasValidSubscription - ); - }); - - return { - isSubscriber, - subscriptionCanceledAt, - }; -} diff --git a/lib/api/checkSubscriptionByEmail.ts b/lib/api/checkSubscriptionByEmail.ts new file mode 100644 index 0000000..460a168 --- /dev/null +++ b/lib/api/checkSubscriptionByEmail.ts @@ -0,0 +1,52 @@ +import Stripe from "stripe"; + +const MONTHLY_PRICE_ID = process.env.MONTHLY_PRICE_ID; +const YEARLY_PRICE_ID = process.env.YEARLY_PRICE_ID; +const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; + +export default async function checkSubscriptionByEmail(email: string) { + let active: boolean | undefined, + stripeSubscriptionId: string | undefined, + currentPeriodStart: number | undefined, + currentPeriodEnd: number | undefined; + + if (!STRIPE_SECRET_KEY) + return { + active, + stripeSubscriptionId, + currentPeriodStart, + currentPeriodEnd, + }; + + const stripe = new Stripe(STRIPE_SECRET_KEY, { + apiVersion: "2022-11-15", + }); + + console.log("Request made to Stripe by:", email); + const listByEmail = await stripe.customers.list({ + email: email.toLowerCase(), + expand: ["data.subscriptions"], + }); + + listByEmail.data.some((customer) => { + customer.subscriptions?.data.some((subscription) => { + subscription.current_period_end; + + active = subscription.items.data.some( + (e) => + (e.price.id === MONTHLY_PRICE_ID && e.price.active === true) || + (e.price.id === YEARLY_PRICE_ID && e.price.active === true) + ); + stripeSubscriptionId = subscription.id; + currentPeriodStart = subscription.current_period_start * 1000; + currentPeriodEnd = subscription.current_period_end * 1000; + }); + }); + + return { + active, + stripeSubscriptionId, + currentPeriodStart, + currentPeriodEnd, + }; +} diff --git a/lib/api/controllers/users/userId/getPublicUserById.ts b/lib/api/controllers/public/users/getPublicUserById.ts similarity index 62% rename from lib/api/controllers/users/userId/getPublicUserById.ts rename to lib/api/controllers/public/users/getPublicUserById.ts index c5a575e..8f7ea48 100644 --- a/lib/api/controllers/users/userId/getPublicUserById.ts +++ b/lib/api/controllers/public/users/getPublicUserById.ts @@ -3,7 +3,7 @@ import { prisma } from "@/lib/api/db"; export default async function getPublicUserById( targetId: number | string, isId: boolean, - requestingUsername?: string + requestingId?: number ) { const user = await prisma.user.findUnique({ where: isId @@ -29,12 +29,23 @@ export default async function getPublicUserById( (usernames) => usernames.username ); - if ( - user?.isPrivate && - (!requestingUsername || - !whitelistedUsernames.includes(requestingUsername?.toLowerCase())) - ) { - return { response: "User not found or profile is private.", status: 404 }; + if (user?.isPrivate) { + if (requestingId) { + const requestingUsername = ( + await prisma.user.findUnique({ where: { id: requestingId } }) + )?.username; + + if ( + !requestingUsername || + !whitelistedUsernames.includes(requestingUsername?.toLowerCase()) + ) { + return { + response: "User not found or profile is private.", + status: 404, + }; + } + } else + return { response: "User not found or profile is private.", status: 404 }; } const { password, ...lessSensitiveInfo } = user; diff --git a/lib/api/controllers/users/userId/deleteUserById.ts b/lib/api/controllers/users/userId/deleteUserById.ts index 0641525..4b2e940 100644 --- a/lib/api/controllers/users/userId/deleteUserById.ts +++ b/lib/api/controllers/users/userId/deleteUserById.ts @@ -68,6 +68,11 @@ export default async function deleteUserById( where: { ownerId: userId }, }); + // Delete subscription + await prisma.subscription.delete({ + where: { userId }, + }); + // Delete user's avatar removeFile({ filePath: `uploads/avatar/${userId}.jpg` }); diff --git a/lib/api/controllers/users/userId/getUserById.ts b/lib/api/controllers/users/userId/getUserById.ts index 32bc3a6..8c6faa8 100644 --- a/lib/api/controllers/users/userId/getUserById.ts +++ b/lib/api/controllers/users/userId/getUserById.ts @@ -11,6 +11,7 @@ export default async function getUserById(userId: number) { username: true, }, }, + subscriptions: true, }, }); @@ -21,11 +22,14 @@ export default async function getUserById(userId: number) { (usernames) => usernames.username ); - const { password, ...lessSensitiveInfo } = user; + const { password, subscriptions, ...lessSensitiveInfo } = user; const data = { ...lessSensitiveInfo, whitelistedUsers: whitelistedUsernames, + subscription: { + active: subscriptions?.active, + }, }; return { response: data, status: 200 }; diff --git a/lib/api/controllers/users/userId/updateUserById.ts b/lib/api/controllers/users/userId/updateUserById.ts index fc4c015..0043c8b 100644 --- a/lib/api/controllers/users/userId/updateUserById.ts +++ b/lib/api/controllers/users/userId/updateUserById.ts @@ -139,10 +139,12 @@ export default async function updateUserById( }, include: { whitelistedUsers: true, + subscriptions: true, }, }); - const { whitelistedUsers, password, ...userInfo } = updatedUser; + const { whitelistedUsers, password, subscriptions, ...userInfo } = + updatedUser; // If user.whitelistedUsers is not provided, we will assume the whitelistedUsers should be removed const newWhitelistedUsernames: string[] = data.whitelistedUsers || []; @@ -196,6 +198,7 @@ export default async function updateUserById( ...userInfo, whitelistedUsers: newWhitelistedUsernames, image: userInfo.image ? `${userInfo.image}?${Date.now()}` : "", + subscription: { active: subscriptions?.active }, }; return { response, status: 200 }; diff --git a/lib/api/paymentCheckout.ts b/lib/api/paymentCheckout.ts index 49bf027..bae7f1d 100644 --- a/lib/api/paymentCheckout.ts +++ b/lib/api/paymentCheckout.ts @@ -14,12 +14,12 @@ export default async function paymentCheckout( expand: ["data.subscriptions"], }); - const isExistingCostomer = listByEmail?.data[0]?.id || undefined; + const isExistingCustomer = listByEmail?.data[0]?.id || undefined; const NEXT_PUBLIC_TRIAL_PERIOD_DAYS = process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS; const session = await stripe.checkout.sessions.create({ - customer: isExistingCostomer ? isExistingCostomer : undefined, + customer: isExistingCustomer ? isExistingCustomer : undefined, line_items: [ { price: priceId, @@ -27,7 +27,7 @@ export default async function paymentCheckout( }, ], mode: "subscription", - customer_email: isExistingCostomer ? undefined : email.toLowerCase(), + customer_email: isExistingCustomer ? undefined : email.toLowerCase(), success_url: `${process.env.BASE_URL}?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${process.env.BASE_URL}/login`, automatic_tax: { diff --git a/lib/api/verifySubscription.ts b/lib/api/verifySubscription.ts new file mode 100644 index 0000000..d8055d4 --- /dev/null +++ b/lib/api/verifySubscription.ts @@ -0,0 +1,70 @@ +import { prisma } from "./db"; +import { Subscription, User } from "@prisma/client"; +import checkSubscriptionByEmail from "./checkSubscriptionByEmail"; + +interface UserIncludingSubscription extends User { + subscriptions: Subscription | null; +} + +export default async function verifySubscription( + user?: UserIncludingSubscription +) { + if (!user) { + return null; + } + + const subscription = user.subscriptions; + + const currentDate = new Date(); + + if ( + subscription && + currentDate > subscription.currentPeriodEnd && + !subscription.active + ) { + return null; + } + + if (!subscription || currentDate > subscription.currentPeriodEnd) { + const { + active, + stripeSubscriptionId, + currentPeriodStart, + currentPeriodEnd, + } = await checkSubscriptionByEmail(user.email as string); + + if ( + active && + stripeSubscriptionId && + currentPeriodStart && + currentPeriodEnd + ) { + await prisma.subscription + .upsert({ + where: { + userId: user.id, + }, + create: { + active, + stripeSubscriptionId, + currentPeriodStart: new Date(currentPeriodStart), + currentPeriodEnd: new Date(currentPeriodEnd), + userId: user.id, + }, + update: { + active, + stripeSubscriptionId, + currentPeriodStart: new Date(currentPeriodStart), + currentPeriodEnd: new Date(currentPeriodEnd), + }, + }) + .catch((err) => console.log(err)); + } + + if (!active) { + return null; + } + } + + return user; +} diff --git a/lib/api/verifyUser.ts b/lib/api/verifyUser.ts new file mode 100644 index 0000000..943f68e --- /dev/null +++ b/lib/api/verifyUser.ts @@ -0,0 +1,60 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { getToken } from "next-auth/jwt"; +import { prisma } from "./db"; +import { User } from "@prisma/client"; +import verifySubscription from "./verifySubscription"; + +type Props = { + req: NextApiRequest; + res: NextApiResponse; +}; + +const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; + +export default async function verifyUser({ + req, + res, +}: Props): Promise { + const token = await getToken({ req }); + const userId = token?.id; + + if (!userId) { + res.status(401).json({ response: "You must be logged in." }); + return null; + } + + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + include: { + subscriptions: true, + }, + }); + + if (!user) { + res.status(404).json({ response: "User not found." }); + return null; + } + + if (!user.username) { + res.status(401).json({ + response: "Username not found.", + }); + return null; + } + + if (STRIPE_SECRET_KEY) { + const subscribedUser = verifySubscription(user); + + if (!subscribedUser) { + res.status(401).json({ + response: + "You are not a subscriber, feel free to reach out to us at support@linkwarden.app if you think this is an issue.", + }); + return null; + } + } + + return user; +} diff --git a/lib/client/getPublicUserData.ts b/lib/client/getPublicUserData.ts index 20bd1e9..595ea7c 100644 --- a/lib/client/getPublicUserData.ts +++ b/lib/client/getPublicUserData.ts @@ -1,7 +1,7 @@ import { toast } from "react-hot-toast"; export default async function getPublicUserData(id: number | string) { - const response = await fetch(`/api/v1/users/${id}`); + const response = await fetch(`/api/v1/public/users/${id}`); const data = await response.json(); diff --git a/pages/_app.tsx b/pages/_app.tsx index 69d8b6f..25217f9 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -22,7 +22,11 @@ export default function App({ }, []); return ( - + Linkwarden diff --git a/pages/api/v1/archives/[...params].ts b/pages/api/v1/archives/[...params].ts index 5534ea3..1436c04 100644 --- a/pages/api/v1/archives/[...params].ts +++ b/pages/api/v1/archives/[...params].ts @@ -1,14 +1,14 @@ import type { NextApiRequest, NextApiResponse } from "next"; import getPermission from "@/lib/api/getPermission"; import readFile from "@/lib/api/storage/readFile"; -import authenticateUser from "@/lib/api/authenticateUser"; +import verifyUser from "@/lib/api/verifyUser"; export default async function Index(req: NextApiRequest, res: NextApiResponse) { if (!req.query.params) return res.status(401).json({ response: "Invalid parameters." }); - const user = await authenticateUser({ req, res }); - if (!user) return res.status(404).json({ response: "User not found." }); + const user = await verifyUser({ req, res }); + if (!user) return; const collectionId = req.query.params[0]; const linkId = req.query.params[1]; diff --git a/pages/api/v1/auth/[...nextauth].ts b/pages/api/v1/auth/[...nextauth].ts index 3228a35..0b4ab7e 100644 --- a/pages/api/v1/auth/[...nextauth].ts +++ b/pages/api/v1/auth/[...nextauth].ts @@ -1,24 +1,26 @@ import { prisma } from "@/lib/api/db"; import NextAuth from "next-auth/next"; import CredentialsProvider from "next-auth/providers/credentials"; -import { AuthOptions, Session, User } from "next-auth"; +import { AuthOptions } from "next-auth"; import bcrypt from "bcrypt"; import EmailProvider from "next-auth/providers/email"; import { PrismaAdapter } from "@auth/prisma-adapter"; import { Adapter } from "next-auth/adapters"; import sendVerificationRequest from "@/lib/api/sendVerificationRequest"; import { Provider } from "next-auth/providers"; -import checkSubscription from "@/lib/api/checkSubscription"; +import verifySubscription from "@/lib/api/verifySubscription"; const emailEnabled = process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false; +const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; + const providers: Provider[] = [ CredentialsProvider({ type: "credentials", credentials: {}, async authorize(credentials, req) { - console.log("User logged in attempt..."); + console.log("User log in attempt..."); if (!credentials) return null; const { username, password } = credentials as { @@ -26,7 +28,7 @@ const providers: Provider[] = [ password: string; }; - const findUser = await prisma.user.findFirst({ + const user = await prisma.user.findFirst({ where: emailEnabled ? { OR: [ @@ -46,12 +48,12 @@ const providers: Provider[] = [ let passwordMatches: boolean = false; - if (findUser?.password) { - passwordMatches = bcrypt.compareSync(password, findUser.password); + if (user?.password) { + passwordMatches = bcrypt.compareSync(password, user.password); } if (passwordMatches) { - return { id: findUser?.id }; + return { id: user?.id }; } else return null as any; }, }), @@ -82,62 +84,28 @@ export const authOptions: AuthOptions = { }, callbacks: { async jwt({ token, trigger, user }) { - const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; - - const NEXT_PUBLIC_TRIAL_PERIOD_DAYS = - process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS; - const secondsInTwoWeeks = NEXT_PUBLIC_TRIAL_PERIOD_DAYS - ? Number(NEXT_PUBLIC_TRIAL_PERIOD_DAYS) * 86400 - : 1209600; - const subscriptionIsTimesUp = - token.subscriptionCanceledAt && - new Date() > - new Date( - ((token.subscriptionCanceledAt as number) + secondsInTwoWeeks) * - 1000 - ); - - if ( - STRIPE_SECRET_KEY && - (trigger || subscriptionIsTimesUp || !token.isSubscriber) - ) { - const user = await prisma.user.findUnique({ - where: { - id: Number(token.sub), - }, - }); - - const subscription = await checkSubscription( - STRIPE_SECRET_KEY, - user?.email as string - ); - - if (subscription.subscriptionCanceledAt) { - token.subscriptionCanceledAt = subscription.subscriptionCanceledAt; - } else token.subscriptionCanceledAt = undefined; - - token.isSubscriber = subscription.isSubscriber; - } - - if (trigger === "signIn") { - token.id = user.id as number; - } else if (trigger === "update" && token.id) { - const user = await prisma.user.findUnique({ - where: { - id: token.id as number, - }, - }); - - if (user?.name) { - token.name = user.name; - } - } + token.sub = token.sub ? Number(token.sub) : undefined; + if (trigger === "signIn") token.id = user?.id as number; return token; }, async session({ session, token }) { session.user.id = token.id; - session.user.isSubscriber = token.isSubscriber; + + if (STRIPE_SECRET_KEY) { + const user = await prisma.user.findUnique({ + where: { + id: token.id, + }, + include: { + subscriptions: true, + }, + }); + + if (user) { + const subscribedUser = await verifySubscription(user); + } + } return session; }, diff --git a/pages/api/v1/avatar/[id].ts b/pages/api/v1/avatar/[id].ts index 307cb8a..f20b9b6 100644 --- a/pages/api/v1/avatar/[id].ts +++ b/pages/api/v1/avatar/[id].ts @@ -1,13 +1,13 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { prisma } from "@/lib/api/db"; import readFile from "@/lib/api/storage/readFile"; -import authenticateUser from "@/lib/api/authenticateUser"; +import verifyUser from "@/lib/api/verifyUser"; export default async function Index(req: NextApiRequest, res: NextApiResponse) { const queryId = Number(req.query.id); - const user = await authenticateUser({ req, res }); - if (!user) return res.status(404).json({ response: "User not found." }); + const user = await verifyUser({ req, res }); + if (!user) return; if (!queryId) return res diff --git a/pages/api/v1/collections/[id].ts b/pages/api/v1/collections/[id].ts index 6f98690..4f8020c 100644 --- a/pages/api/v1/collections/[id].ts +++ b/pages/api/v1/collections/[id].ts @@ -1,14 +1,14 @@ import type { NextApiRequest, NextApiResponse } from "next"; import updateCollectionById from "@/lib/api/controllers/collections/collectionId/updateCollectionById"; import deleteCollectionById from "@/lib/api/controllers/collections/collectionId/deleteCollectionById"; -import authenticateUser from "@/lib/api/authenticateUser"; +import verifyUser from "@/lib/api/verifyUser"; export default async function collections( req: NextApiRequest, res: NextApiResponse ) { - const user = await authenticateUser({ req, res }); - if (!user) return res.status(404).json({ response: "User not found." }); + const user = await verifyUser({ req, res }); + if (!user) return; if (req.method === "PUT") { const updated = await updateCollectionById( diff --git a/pages/api/v1/collections/index.ts b/pages/api/v1/collections/index.ts index 5f938f5..3b229dd 100644 --- a/pages/api/v1/collections/index.ts +++ b/pages/api/v1/collections/index.ts @@ -1,14 +1,14 @@ import type { NextApiRequest, NextApiResponse } from "next"; import getCollections from "@/lib/api/controllers/collections/getCollections"; import postCollection from "@/lib/api/controllers/collections/postCollection"; -import authenticateUser from "@/lib/api/authenticateUser"; +import verifyUser from "@/lib/api/verifyUser"; export default async function collections( req: NextApiRequest, res: NextApiResponse ) { - const user = await authenticateUser({ req, res }); - if (!user) return res.status(404).json({ response: "User not found." }); + const user = await verifyUser({ req, res }); + if (!user) return; if (req.method === "GET") { const collections = await getCollections(user.id); diff --git a/pages/api/v1/dashboard/index.ts b/pages/api/v1/dashboard/index.ts index dfbd50a..ea2a3da 100644 --- a/pages/api/v1/dashboard/index.ts +++ b/pages/api/v1/dashboard/index.ts @@ -1,11 +1,11 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { LinkRequestQuery } from "@/types/global"; import getDashboardData from "@/lib/api/controllers/dashboard/getDashboardData"; -import authenticateUser from "@/lib/api/authenticateUser"; +import verifyUser from "@/lib/api/verifyUser"; export default async function links(req: NextApiRequest, res: NextApiResponse) { - const user = await authenticateUser({ req, res }); - if (!user) return res.status(404).json({ response: "User not found." }); + const user = await verifyUser({ req, res }); + if (!user) return; if (req.method === "GET") { const convertedData: LinkRequestQuery = { diff --git a/pages/api/v1/links/[id]/archive/index.ts b/pages/api/v1/links/[id]/archive/index.ts index 2306202..7fc141f 100644 --- a/pages/api/v1/links/[id]/archive/index.ts +++ b/pages/api/v1/links/[id]/archive/index.ts @@ -1,13 +1,13 @@ import type { NextApiRequest, NextApiResponse } from "next"; import archive from "@/lib/api/archive"; import { prisma } from "@/lib/api/db"; -import authenticateUser from "@/lib/api/authenticateUser"; +import verifyUser from "@/lib/api/verifyUser"; const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5; export default async function links(req: NextApiRequest, res: NextApiResponse) { - const user = await authenticateUser({ req, res }); - if (!user) return res.status(404).json({ response: "User not found." }); + const user = await verifyUser({ req, res }); + if (!user) return; const link = await prisma.link.findUnique({ where: { diff --git a/pages/api/v1/links/[id]/index.ts b/pages/api/v1/links/[id]/index.ts index 290aea5..146ecb2 100644 --- a/pages/api/v1/links/[id]/index.ts +++ b/pages/api/v1/links/[id]/index.ts @@ -2,11 +2,11 @@ import type { NextApiRequest, NextApiResponse } from "next"; import deleteLinkById from "@/lib/api/controllers/links/linkId/deleteLinkById"; import updateLinkById from "@/lib/api/controllers/links/linkId/updateLinkById"; import getLinkById from "@/lib/api/controllers/links/linkId/getLinkById"; -import authenticateUser from "@/lib/api/authenticateUser"; +import verifyUser from "@/lib/api/verifyUser"; export default async function links(req: NextApiRequest, res: NextApiResponse) { - const user = await authenticateUser({ req, res }); - if (!user) return res.status(404).json({ response: "User not found." }); + const user = await verifyUser({ req, res }); + if (!user) return; if (req.method === "GET") { const updated = await getLinkById(user.id, Number(req.query.id)); diff --git a/pages/api/v1/links/index.ts b/pages/api/v1/links/index.ts index 35dcacc..58a352a 100644 --- a/pages/api/v1/links/index.ts +++ b/pages/api/v1/links/index.ts @@ -2,11 +2,11 @@ import type { NextApiRequest, NextApiResponse } from "next"; import getLinks from "@/lib/api/controllers/links/getLinks"; import postLink from "@/lib/api/controllers/links/postLink"; import { LinkRequestQuery } from "@/types/global"; -import authenticateUser from "@/lib/api/authenticateUser"; +import verifyUser from "@/lib/api/verifyUser"; export default async function links(req: NextApiRequest, res: NextApiResponse) { - const user = await authenticateUser({ req, res }); - if (!user) return res.status(404).json({ response: "User not found." }); + const user = await verifyUser({ req, res }); + if (!user) return; if (req.method === "GET") { // Convert the type of the request query to "LinkRequestQuery" diff --git a/pages/api/v1/migration/index.ts b/pages/api/v1/migration/index.ts index 74db8c6..4afd7e7 100644 --- a/pages/api/v1/migration/index.ts +++ b/pages/api/v1/migration/index.ts @@ -3,7 +3,7 @@ import exportData from "@/lib/api/controllers/migration/exportData"; import importFromHTMLFile from "@/lib/api/controllers/migration/importFromHTMLFile"; import importFromLinkwarden from "@/lib/api/controllers/migration/importFromLinkwarden"; import { MigrationFormat, MigrationRequest } from "@/types/global"; -import authenticateUser from "@/lib/api/authenticateUser"; +import verifyUser from "@/lib/api/verifyUser"; export const config = { api: { @@ -14,8 +14,8 @@ export const config = { }; export default async function users(req: NextApiRequest, res: NextApiResponse) { - const user = await authenticateUser({ req, res }); - if (!user) return res.status(404).json({ response: "User not found." }); + const user = await verifyUser({ req, res }); + if (!user) return; if (req.method === "GET") { const data = await exportData(user.id); diff --git a/pages/api/v1/payment/index.ts b/pages/api/v1/payment/index.ts index 2404610..779d646 100644 --- a/pages/api/v1/payment/index.ts +++ b/pages/api/v1/payment/index.ts @@ -1,19 +1,27 @@ import type { NextApiRequest, NextApiResponse } from "next"; import paymentCheckout from "@/lib/api/paymentCheckout"; import { Plan } from "@/types/global"; -import authenticateUser from "@/lib/api/authenticateUser"; +import { getToken } from "next-auth/jwt"; +import { prisma } from "@/lib/api/db"; export default async function users(req: NextApiRequest, res: NextApiResponse) { const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; const MONTHLY_PRICE_ID = process.env.MONTHLY_PRICE_ID; const YEARLY_PRICE_ID = process.env.YEARLY_PRICE_ID; - if (!STRIPE_SECRET_KEY || !MONTHLY_PRICE_ID || !YEARLY_PRICE_ID) { - return res.status(400).json({ response: "Payment is disabled." }); - } + const token = await getToken({ req }); - const user = await authenticateUser({ req, res }); - if (!user) return res.status(404).json({ response: "User not found." }); + if (!STRIPE_SECRET_KEY || !MONTHLY_PRICE_ID || !YEARLY_PRICE_ID) + return res.status(400).json({ response: "Payment is disabled." }); + + console.log(token); + + if (!token?.id) return res.status(404).json({ response: "Token invalid." }); + + const email = (await prisma.user.findUnique({ where: { id: token.id } })) + ?.email; + + if (!email) return res.status(404).json({ response: "User not found." }); let PRICE_ID = MONTHLY_PRICE_ID; @@ -25,7 +33,7 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) { if (req.method === "GET") { const users = await paymentCheckout( STRIPE_SECRET_KEY, - user.email as string, + email as string, PRICE_ID ); return res.status(users.status).json({ response: users.response }); diff --git a/pages/api/v1/public/users/[id].ts b/pages/api/v1/public/users/[id].ts new file mode 100644 index 0000000..5126740 --- /dev/null +++ b/pages/api/v1/public/users/[id].ts @@ -0,0 +1,18 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import getPublicUserById from "@/lib/api/controllers/public/users/getPublicUserById"; +import { getToken } from "next-auth/jwt"; + +export default async function users(req: NextApiRequest, res: NextApiResponse) { + const token = await getToken({ req }); + const requestingId = token?.id; + + const lookupId = req.query.id as string; + + // Check if "lookupId" is the user "id" or their "username" + const isId = lookupId.split("").every((e) => Number.isInteger(parseInt(e))); + + if (req.method === "GET") { + const users = await getPublicUserById(lookupId, isId, requestingId); + return res.status(users.status).json({ response: users.response }); + } +} diff --git a/pages/api/v1/tags/[id].ts b/pages/api/v1/tags/[id].ts index 3a6b5f3..d82b1f7 100644 --- a/pages/api/v1/tags/[id].ts +++ b/pages/api/v1/tags/[id].ts @@ -1,11 +1,11 @@ import type { NextApiRequest, NextApiResponse } from "next"; import updeteTagById from "@/lib/api/controllers/tags/tagId/updeteTagById"; -import authenticateUser from "@/lib/api/authenticateUser"; +import verifyUser from "@/lib/api/verifyUser"; import deleteTagById from "@/lib/api/controllers/tags/tagId/deleteTagById"; export default async function tags(req: NextApiRequest, res: NextApiResponse) { - const user = await authenticateUser({ req, res }); - if (!user) return res.status(404).json({ response: "User not found." }); + const user = await verifyUser({ req, res }); + if (!user) return; const tagId = Number(req.query.id); diff --git a/pages/api/v1/tags/index.ts b/pages/api/v1/tags/index.ts index dd4cd39..2376ef9 100644 --- a/pages/api/v1/tags/index.ts +++ b/pages/api/v1/tags/index.ts @@ -1,10 +1,10 @@ import type { NextApiRequest, NextApiResponse } from "next"; import getTags from "@/lib/api/controllers/tags/getTags"; -import authenticateUser from "@/lib/api/authenticateUser"; +import verifyUser from "@/lib/api/verifyUser"; export default async function tags(req: NextApiRequest, res: NextApiResponse) { - const user = await authenticateUser({ req, res }); - if (!user) return res.status(404).json({ response: "User not found." }); + const user = await verifyUser({ req, res }); + if (!user) return; if (req.method === "GET") { const tags = await getTags(user.id); diff --git a/pages/api/v1/users/[id].ts b/pages/api/v1/users/[id].ts index 0c69c25..6063d53 100644 --- a/pages/api/v1/users/[id].ts +++ b/pages/api/v1/users/[id].ts @@ -1,48 +1,58 @@ import type { NextApiRequest, NextApiResponse } from "next"; import getUserById from "@/lib/api/controllers/users/userId/getUserById"; -import getPublicUserById from "@/lib/api/controllers/users/userId/getPublicUserById"; import updateUserById from "@/lib/api/controllers/users/userId/updateUserById"; import deleteUserById from "@/lib/api/controllers/users/userId/deleteUserById"; -import authenticateUser from "@/lib/api/authenticateUser"; -import { prisma } from "@/lib/api/db"; import { getToken } from "next-auth/jwt"; +import { prisma } from "@/lib/api/db"; +import verifySubscription from "@/lib/api/verifySubscription"; + +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; - if (!token?.id) - return res.status(400).json({ response: "Invalid parameters." }); + if (!userId) { + return res.status(401).json({ response: "You must be logged in." }); + } - const username = (await prisma.user.findUnique({ where: { id: token.id } })) - ?.username; + if (userId !== Number(req.query.id)) + return res.status(401).json({ response: "Permission denied." }); - if (!username) return res.status(404).json({ response: "User not found." }); - - const lookupId = req.query.id as string; - const isSelf = - userId === Number(lookupId) || username === lookupId ? true : false; - - // Check if "lookupId" is the user "id" or their "username" - const isId = lookupId.split("").every((e) => Number.isInteger(parseInt(e))); - - if (req.method === "GET" && !isSelf) { - const users = await getPublicUserById(lookupId, isId, username); + if (req.method === "GET") { + const users = await getUserById(userId); return res.status(users.status).json({ response: users.response }); } - const user = await authenticateUser({ req, res }); - if (!user) return res.status(404).json({ response: "User not found." }); + if (STRIPE_SECRET_KEY) { + const user = await prisma.user.findUnique({ + where: { + id: token.id, + }, + include: { + subscriptions: true, + }, + }); - if (req.method === "GET") { - const users = await getUserById(user.id); - return res.status(users.status).json({ response: users.response }); - } else if (req.method === "PUT") { - const updated = await updateUserById(user.id, req.body); + if (user) { + const subscribedUser = await verifySubscription(user); + if (!subscribedUser) { + return res.status(401).json({ + response: + "You are not a subscriber, feel free to reach out to us at support@linkwarden.app if you think this is an issue.", + }); + } + } else { + return res.status(404).json({ response: "User not found." }); + } + } + + if (req.method === "PUT") { + const updated = await updateUserById(userId, req.body); return res.status(updated.status).json({ response: updated.response }); - } else if (req.method === "DELETE" && user.id === Number(req.query.id)) { + } else if (req.method === "DELETE") { console.log(req.body); - const updated = await deleteUserById(user.id, req.body); + const updated = await deleteUserById(userId, req.body); return res.status(updated.status).json({ response: updated.response }); } } diff --git a/pages/index.tsx b/pages/index.tsx index e4679ca..aa5897c 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,10 +1,3 @@ -import { useRouter } from "next/router"; -import { useEffect } from "react"; - export default function Index() { - const router = useRouter(); - - useEffect(() => { - router.push("/dashboard"); - }, []); + return null; } diff --git a/pages/register.tsx b/pages/register.tsx index 62ce26d..f836843 100644 --- a/pages/register.tsx +++ b/pages/register.tsx @@ -96,7 +96,7 @@ export default function Register() { return (
- {process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE ? ( + {process.env.NEXT_PUBLIC_STRIPE ? (

By signing up, you agree to our{" "} diff --git a/pages/settings/account.tsx b/pages/settings/account.tsx index d177025..0642424 100644 --- a/pages/settings/account.tsx +++ b/pages/settings/account.tsx @@ -86,20 +86,6 @@ export default function Account() { if (response.ok) { toast.success("Settings Applied!"); - - // if (user.email !== account.email) { - // update({ - // id: data?.user.id, - // }); - - // signOut(); - // } else if ( - // user.username !== account.username || - // user.name !== account.name - // ) - // update({ - // id: data?.user.id, - // }); } else toast.error(response.data as string); setSubmitLoader(false); }; @@ -190,7 +176,7 @@ export default function Account() {

Email

{user.email !== account.email && - process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE === "true" ? ( + process.env.NEXT_PUBLIC_STRIPE === "true" ? (

Updating this field will change your billing email as well

@@ -388,7 +374,7 @@ export default function Account() {

This will permanently delete ALL the Links, Collections, Tags, and archived data you own.{" "} - {process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE + {process.env.NEXT_PUBLIC_STRIPE ? "It will also cancel your subscription. " : undefined}{" "} You will be prompted to enter your password before the deletion diff --git a/pages/settings/billing.tsx b/pages/settings/billing.tsx index 185f2a7..953d1ee 100644 --- a/pages/settings/billing.tsx +++ b/pages/settings/billing.tsx @@ -6,8 +6,7 @@ export default function Billing() { const router = useRouter(); useEffect(() => { - if (!process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE) - router.push("/settings/profile"); + if (!process.env.NEXT_PUBLIC_STRIPE) router.push("/settings/profile"); }, []); return ( diff --git a/pages/settings/delete.tsx b/pages/settings/delete.tsx index a0096e1..bd0c632 100644 --- a/pages/settings/delete.tsx +++ b/pages/settings/delete.tsx @@ -72,7 +72,7 @@ export default function Password() {

This will permanently delete all the Links, Collections, Tags, and archived data you own. It will also log you out - {process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE + {process.env.NEXT_PUBLIC_STRIPE ? " and cancel your subscription" : undefined} . This action is irreversible! @@ -91,7 +91,7 @@ export default function Password() { />

- {process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE ? ( + {process.env.NEXT_PUBLIC_STRIPE ? (
Optional{" "} diff --git a/pages/settings/password.tsx b/pages/settings/password.tsx index 90b8abf..fe3aa35 100644 --- a/pages/settings/password.tsx +++ b/pages/settings/password.tsx @@ -1,7 +1,6 @@ import SettingsLayout from "@/layouts/SettingsLayout"; import { useState } from "react"; import useAccountStore from "@/store/account"; -import { faPenToSquare } from "@fortawesome/free-regular-svg-icons"; import SubmitButton from "@/components/SubmitButton"; import { toast } from "react-hot-toast"; import TextInput from "@/components/TextInput"; diff --git a/pages/subscribe.tsx b/pages/subscribe.tsx index 4398b9a..2edd4ba 100644 --- a/pages/subscribe.tsx +++ b/pages/subscribe.tsx @@ -1,4 +1,4 @@ -import { signOut } from "next-auth/react"; +import { signOut, useSession } from "next-auth/react"; import { useState } from "react"; import { toast } from "react-hot-toast"; import { useRouter } from "next/router"; @@ -7,6 +7,7 @@ import { Plan } from "@/types/global"; export default function Subscribe() { const [submitLoader, setSubmitLoader] = useState(false); + const session = useSession(); const [plan, setPlan] = useState(1); @@ -83,7 +84,7 @@ export default function Subscribe() {

Billed {plan === Plan.monthly ? "Monthly" : "Yearly"}

-
+
Total diff --git a/prisma/migrations/20231103051515_add_subscription_table/migration.sql b/prisma/migrations/20231103051515_add_subscription_table/migration.sql new file mode 100644 index 0000000..07f28f2 --- /dev/null +++ b/prisma/migrations/20231103051515_add_subscription_table/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "Subscription" ( + "id" SERIAL NOT NULL, + "active" BOOLEAN NOT NULL, + "stripeSubscriptionId" TEXT NOT NULL, + "currentPeriodStart" TIMESTAMP(3) NOT NULL, + "currentPeriodEnd" TIMESTAMP(3) NOT NULL, + "userId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Subscription_stripeSubscriptionId_key" ON "Subscription"("stripeSubscriptionId"); + +-- AddForeignKey +ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20231104052926_changed_subscription_relation/migration.sql b/prisma/migrations/20231104052926_changed_subscription_relation/migration.sql new file mode 100644 index 0000000..0dad558 --- /dev/null +++ b/prisma/migrations/20231104052926_changed_subscription_relation/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[userId]` on the table `Subscription` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "Subscription_userId_key" ON "Subscription"("userId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f5574e2..13b755f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -18,19 +18,21 @@ model User { image String? password String + collections Collection[] - tags Tag[] - pinnedLinks Link[] + collectionsJoined UsersAndCollections[] + whitelistedUsers WhitelistedUser[] + + subscriptions Subscription? archiveAsScreenshot Boolean @default(true) archiveAsPDF Boolean @default(true) archiveAsWaybackMachine Boolean @default(false) - collectionsJoined UsersAndCollections[] isPrivate Boolean @default(false) - whitelistedUsers WhitelistedUser[] + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @default(now()) } @@ -127,3 +129,17 @@ model Tag { @@unique([name, ownerId]) } + +model Subscription { + id Int @id @default(autoincrement()) + active Boolean + stripeSubscriptionId String @unique + currentPeriodStart DateTime + currentPeriodEnd DateTime + + user User @relation(fields: [userId], references: [id]) + userId Int @unique + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @default(now()) +} diff --git a/styles/globals.css b/styles/globals.css index 9621be8..a2c51d2 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -2,6 +2,11 @@ @tailwind components; @tailwind utilities; +html, +body { + scroll-behavior: smooth; +} + /* Hide scrollbar */ .hide-scrollbar::-webkit-scrollbar { display: none; diff --git a/types/enviornment.d.ts b/types/enviornment.d.ts index f529759..b8da6f2 100644 --- a/types/enviornment.d.ts +++ b/types/enviornment.d.ts @@ -17,11 +17,11 @@ declare global { BUCKET_NAME?: string; SPACES_REGION?: string; - NEXT_PUBLIC_EMAIL_PROVIDER?: true; + NEXT_PUBLIC_EMAIL_PROVIDER?: string; EMAIL_FROM?: string; EMAIL_SERVER?: string; - NEXT_PUBLIC_STRIPE_IS_ACTIVE?: string; + NEXT_PUBLIC_STRIPE?: string; STRIPE_SECRET_KEY?: string; MONTHLY_PRICE_ID?: string; YEARLY_PRICE_ID?: string; diff --git a/types/global.ts b/types/global.ts index 6daf62a..ce461e3 100644 --- a/types/global.ts +++ b/types/global.ts @@ -40,6 +40,9 @@ export interface CollectionIncludingMembersAndLinkCount export interface AccountSettings extends User { newPassword?: string; whitelistedUsers: string[]; + subscription?: { + active?: boolean; + }; } interface LinksIncludingTags extends Link { diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts index e412e7e..e129025 100644 --- a/types/next-auth.d.ts +++ b/types/next-auth.d.ts @@ -5,7 +5,6 @@ declare module "next-auth" { interface Session { user: { id: number; - isSubscriber: boolean; }; } @@ -16,9 +15,8 @@ declare module "next-auth" { declare module "next-auth/jwt" { interface JWT { - sub: string; + sub?: number; id: number; - isSubscriber: boolean; iat: number; exp: number; jti: string;