diff --git a/.env.sample b/.env.sample index e6faff3..3413f7c 100644 --- a/.env.sample +++ b/.env.sample @@ -15,4 +15,10 @@ SPACES_REGION= # SMTP Settings (Optional) NEXT_PUBLIC_EMAIL_PROVIDER= EMAIL_FROM= -EMAIL_SERVER= \ No newline at end of file +EMAIL_SERVER= + +# Stripe settings (You don't need these, it's for the cloud instance payments) +STRIPE_SECRET_KEY= +PRICE_ID= +TRIAL_PERIOD_DAYS= +NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL= \ No newline at end of file diff --git a/components/InputSelect/CollectionSelection.tsx b/components/InputSelect/CollectionSelection.tsx index 238b8f0..d7307a0 100644 --- a/components/InputSelect/CollectionSelection.tsx +++ b/components/InputSelect/CollectionSelection.tsx @@ -45,6 +45,7 @@ export default function CollectionSelection({ onChange, defaultValue }: Props) { return ( Password + + {STRIPE_BILLING_PORTAL_URL ? ( + + selected + ? "px-2 py-1 bg-sky-200 duration-100 rounded-md outline-none" + : "px-2 py-1 hover:bg-slate-200 rounded-md duration-100 outline-none" + } + > + Billing Portal + + ) : undefined} @@ -78,6 +94,12 @@ export default function UserModal({ user={user} /> + + {STRIPE_BILLING_PORTAL_URL ? ( + + + + ) : undefined} diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index b576344..d7dc970 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -60,7 +60,7 @@ export default function Sidebar({ className }: { className?: string }) { active === "/dashboard" ? "bg-sky-200" : "hover:bg-slate-200 bg-gray-100" - } outline-sky-100 outline-1 duration-100 py-1 px-2 rounded-md cursor-pointer flex justify-center flex-col items-center gap-1`} + } outline-sky-100 outline-1 duration-100 py-1 px-2 rounded-md cursor-pointer flex justify-center flex-col items-center gap-1 w-full`} > -

All Links

+

+ All Links +

{ if (!router.pathname.startsWith("/public")) { - if ( + if (status === "authenticated" && data.user.isSubscriber === false) { + router.push("/subscribe").then(() => { + setRedirect(false); + }); + } else if ( status === "authenticated" && (router.pathname === "/login" || router.pathname === "/register" || router.pathname === "/confirmation" || + router.pathname === "/subscribe" || router.pathname === "/forgot") ) { router.push("/").then(() => { diff --git a/layouts/MainLayout.tsx b/layouts/MainLayout.tsx index 7bcac66..de0dea9 100644 --- a/layouts/MainLayout.tsx +++ b/layouts/MainLayout.tsx @@ -6,17 +6,26 @@ import Loader from "../components/Loader"; import useRedirect from "@/hooks/useRedirect"; import { useRouter } from "next/router"; import ModalManagement from "@/components/ModalManagement"; +import useModalStore from "@/store/modals"; interface Props { children: ReactNode; } export default function MainLayout({ children }: Props) { - const { status } = useSession(); + const { status, data } = useSession(); const router = useRouter(); const redirect = useRedirect(); const routeExists = router.route === "/_error" ? false : true; + const { modal } = useModalStore(); + + useEffect(() => { + modal + ? (document.body.style.overflow = "hidden") + : (document.body.style.overflow = "auto"); + }, [modal]); + if (status === "authenticated" && !redirect && routeExists) return ( <> diff --git a/lib/api/checkSubscription.ts b/lib/api/checkSubscription.ts new file mode 100644 index 0000000..67f001c --- /dev/null +++ b/lib/api/checkSubscription.ts @@ -0,0 +1,53 @@ +import Stripe from "stripe"; + +export default async function checkSubscription( + stripeSecretKey: string, + email: string, + priceId: 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 TRIAL_PERIOD_DAYS = process.env.TRIAL_PERIOD_DAYS; + const secondsInTwoWeeks = TRIAL_PERIOD_DAYS + ? Number(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?.some( + (subscriptionItem) => subscriptionItem?.plan?.id === priceId + ) && isNotCanceledOrHasTime + ); + } + ); + + return ( + customer.email?.toLowerCase() === email.toLowerCase() && + hasValidSubscription + ); + }); + + return { + isSubscriber, + subscriptionCanceledAt, + }; +} diff --git a/lib/api/controllers/links/postLink.ts b/lib/api/controllers/links/postLink.ts index 940e318..2991235 100644 --- a/lib/api/controllers/links/postLink.ts +++ b/lib/api/controllers/links/postLink.ts @@ -4,6 +4,7 @@ import getTitle from "@/lib/api/getTitle"; import archive from "@/lib/api/archive"; import { Collection, Link, UsersAndCollections } from "@prisma/client"; import getPermission from "@/lib/api/getPermission"; +import createFolder from "@/lib/api/storage/createFolder"; export default async function postLink( link: LinkIncludingShortenedCollectionAndTags, @@ -14,7 +15,7 @@ export default async function postLink( if (!link.name) { return { response: "Please enter a valid name for the link.", status: 400 }; } else if (!link.collection.name) { - return { response: "Please enter a valid collection.", status: 400 }; + link.collection.name = "Unnamed Collection"; } if (link.collection.id) { @@ -83,6 +84,8 @@ export default async function postLink( include: { tags: true, collection: true }, }); + createFolder({ filePath: `archives/${newLink.collectionId}` }); + archive(newLink.url, newLink.collectionId, newLink.id); return { response: newLink, status: 200 }; diff --git a/lib/api/controllers/users/getUsers.ts b/lib/api/controllers/users/getUsers.ts index 001129c..b75ea7f 100644 --- a/lib/api/controllers/users/getUsers.ts +++ b/lib/api/controllers/users/getUsers.ts @@ -1,13 +1,21 @@ import { prisma } from "@/lib/api/db"; -export default async function getUser( - lookupUsername: string, - isSelf: boolean, - username: string -) { +export default async function getUser({ + params, + isSelf, + username, +}: { + params: { + lookupUsername?: string; + lookupId?: number; + }; + isSelf: boolean; + username: string; +}) { const user = await prisma.user.findUnique({ where: { - username: lookupUsername.toLowerCase(), + id: params.lookupId, + username: params.lookupUsername?.toLowerCase(), }, }); diff --git a/lib/api/controllers/users/updateUser.ts b/lib/api/controllers/users/updateUser.ts index 62fcf3e..064dd84 100644 --- a/lib/api/controllers/users/updateUser.ts +++ b/lib/api/controllers/users/updateUser.ts @@ -3,10 +3,16 @@ import { AccountSettings } from "@/types/global"; import bcrypt from "bcrypt"; import removeFile from "@/lib/api/storage/removeFile"; import createFile from "@/lib/api/storage/createFile"; +import updateCustomerEmail from "../../updateCustomerEmail"; export default async function updateUser( user: AccountSettings, - userId: number + sessionUser: { + id: number; + username: string; + email: string; + isSubscriber: boolean; + } ) { if (!user.username || !user.email) return { @@ -14,6 +20,26 @@ export default async function updateUser( status: 400, }; + const userIsTaken = await prisma.user.findFirst({ + where: { + id: { not: sessionUser.id }, + OR: [ + { + username: user.username.toLowerCase(), + }, + { + email: user.email.toLowerCase(), + }, + ], + }, + }); + + if (userIsTaken) + return { + response: "Username/Email is taken.", + status: 400, + }; + // Avatar Settings const profilePic = user.profilePic; @@ -24,7 +50,7 @@ export default async function updateUser( const base64Data = profilePic.replace(/^data:image\/jpeg;base64,/, ""); await createFile({ - filePath: `uploads/avatar/${userId}.jpg`, + filePath: `uploads/avatar/${sessionUser.id}.jpg`, data: base64Data, isBase64: true, }); @@ -39,7 +65,7 @@ export default async function updateUser( }; } } else if (profilePic == "") { - removeFile({ filePath: `uploads/avatar/${userId}.jpg` }); + removeFile({ filePath: `uploads/avatar/${sessionUser.id}.jpg` }); } // Other settings @@ -49,7 +75,7 @@ export default async function updateUser( const updatedUser = await prisma.user.update({ where: { - id: userId, + id: sessionUser.id, }, data: { name: user.name, @@ -64,6 +90,17 @@ export default async function updateUser( }, }); + const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; + const PRICE_ID = process.env.PRICE_ID; + + if (STRIPE_SECRET_KEY && PRICE_ID) + await updateCustomerEmail( + STRIPE_SECRET_KEY, + PRICE_ID, + sessionUser.email, + user.email + ); + const { password, ...userInfo } = updatedUser; const response: Omit = { diff --git a/lib/api/paymentCheckout.ts b/lib/api/paymentCheckout.ts new file mode 100644 index 0000000..63dac04 --- /dev/null +++ b/lib/api/paymentCheckout.ts @@ -0,0 +1,74 @@ +import Stripe from "stripe"; +import checkSubscription from "./checkSubscription"; + +export default async function paymentCheckout( + stripeSecretKey: string, + email: string, + action: "register" | "login", + priceId: string +) { + const stripe = new Stripe(stripeSecretKey, { + apiVersion: "2022-11-15", + }); + + // const a = await stripe.prices.retrieve("price_1NTn3PDaRUw6CJPLkw4dcwlJ"); + + // const listBySub = await stripe.subscriptions.list({ + // customer: "cus_OGUzJrRea8Qbxx", + // }); + + const listByEmail = await stripe.customers.list({ + email: email.toLowerCase(), + expand: ["data.subscriptions"], + }); + + const isExistingCostomer = listByEmail?.data[0]?.id || undefined; + + // const hasPreviouslySubscribed = listByEmail.data.find((customer, i) => { + // const hasValidSubscription = customer.subscriptions?.data.some( + // (subscription) => { + // return subscription?.items?.data?.some( + // (subscriptionItem) => subscriptionItem?.plan?.id === priceId + // ); + // } + // ); + + // return ( + // customer.email?.toLowerCase() === email.toLowerCase() && + // hasValidSubscription + // ); + // }); + + // const previousSubscriptionId = + // hasPreviouslySubscribed?.subscriptions?.data[0].id; + + // if (previousSubscriptionId) { + // console.log(previousSubscriptionId); + // const subscription = await stripe.subscriptions.resume( + // previousSubscriptionId + // ); + // } + + const TRIAL_PERIOD_DAYS = process.env.TRIAL_PERIOD_DAYS; + const session = await stripe.checkout.sessions.create({ + customer: isExistingCostomer ? isExistingCostomer : undefined, + line_items: [ + { + price: priceId, + quantity: 1, + }, + ], + mode: "subscription", + customer_email: isExistingCostomer ? undefined : email.toLowerCase(), + success_url: "http://localhost:3000?session_id={CHECKOUT_SESSION_ID}", + cancel_url: "http://localhost:3000/login", + automatic_tax: { + enabled: true, + }, + subscription_data: { + trial_period_days: TRIAL_PERIOD_DAYS ? Number(TRIAL_PERIOD_DAYS) : 14, + }, + }); + + return { response: session.url, status: 200 }; +} diff --git a/lib/api/storage/createFolder.ts b/lib/api/storage/createFolder.ts index 18fc0d7..7c2f0b0 100644 --- a/lib/api/storage/createFolder.ts +++ b/lib/api/storage/createFolder.ts @@ -4,7 +4,7 @@ import s3Client from "./s3Client"; export default function createFolder({ filePath }: { filePath: string }) { if (s3Client) { - // Do nothing, S3 builds files recursively + // Do nothing, S3 creates directories recursively } else { const storagePath = process.env.STORAGE_FOLDER; const creationPath = path.join(process.cwd(), storagePath + "/" + filePath); diff --git a/lib/api/updateCustomerEmail.ts b/lib/api/updateCustomerEmail.ts new file mode 100644 index 0000000..07af401 --- /dev/null +++ b/lib/api/updateCustomerEmail.ts @@ -0,0 +1,50 @@ +import Stripe from "stripe"; + +export default async function updateCustomerEmail( + stripeSecretKey: string, + priceId: string, + email: string, + newEmail: string +) { + const stripe = new Stripe(stripeSecretKey, { + apiVersion: "2022-11-15", + }); + + const listByEmail = await stripe.customers.list({ + email: email.toLowerCase(), + expand: ["data.subscriptions"], + }); + + const customer = listByEmail.data.find((customer, i) => { + const hasValidSubscription = customer.subscriptions?.data.some( + (subscription) => { + const TRIAL_PERIOD_DAYS = process.env.TRIAL_PERIOD_DAYS; + const secondsInTwoWeeks = TRIAL_PERIOD_DAYS + ? Number(TRIAL_PERIOD_DAYS) * 86400 + : 1209600; + + const isNotCanceledOrHasTime = !( + subscription.canceled_at && + new Date() > + new Date((subscription.canceled_at + secondsInTwoWeeks) * 1000) + ); + + return ( + subscription?.items?.data?.some( + (subscriptionItem) => subscriptionItem?.plan?.id === priceId + ) && isNotCanceledOrHasTime + ); + } + ); + + return ( + customer.email?.toLowerCase() === email.toLowerCase() && + hasValidSubscription + ); + }); + + if (customer) + await stripe.customers.update(customer?.id, { + email: newEmail.toLowerCase(), + }); +} diff --git a/lib/client/addMemberToCollection.ts b/lib/client/addMemberToCollection.ts index deb7fa0..f1ec14f 100644 --- a/lib/client/addMemberToCollection.ts +++ b/lib/client/addMemberToCollection.ts @@ -1,5 +1,5 @@ import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global"; -import getPublicUserDataByUsername from "./getPublicUserDataByUsername"; +import getPublicUserData from "./getPublicUserData"; import { toast } from "react-hot-toast"; const addMemberToCollection = async ( @@ -22,9 +22,9 @@ const addMemberToCollection = async ( memberUsername.trim().toLowerCase() !== ownerUsername.toLowerCase() ) { // Lookup, get data/err, list ... - const user = await getPublicUserDataByUsername( - memberUsername.trim().toLowerCase() - ); + const user = await getPublicUserData({ + username: memberUsername.trim().toLowerCase(), + }); if (user.username) { setMember({ diff --git a/lib/client/getPublicUserData.ts b/lib/client/getPublicUserData.ts new file mode 100644 index 0000000..620cf9e --- /dev/null +++ b/lib/client/getPublicUserData.ts @@ -0,0 +1,21 @@ +import { toast } from "react-hot-toast"; + +export default async function getPublicUserData({ + username, + id, +}: { + username?: string; + id?: number; +}) { + const response = await fetch( + `/api/routes/users?id=${id}&${ + username ? `username=${username?.toLowerCase()}` : undefined + }` + ); + + const data = await response.json(); + + if (!response.ok) toast.error(data.response); + + return data.response; +} diff --git a/lib/client/getPublicUserDataByUsername.ts b/lib/client/getPublicUserDataByUsername.ts deleted file mode 100644 index 4f7662a..0000000 --- a/lib/client/getPublicUserDataByUsername.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { toast } from "react-hot-toast"; - -export default async function getPublicUserDataByEmail(username: string) { - const response = await fetch( - `/api/routes/users?username=${username.toLowerCase()}` - ); - - const data = await response.json(); - - if (!response.ok) toast.error(data.response); - - return data.response; -} diff --git a/package.json b/package.json index 5e33803..cc005e9 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@headlessui/react": "^1.7.15", "@next/font": "13.4.9", "@prisma/client": "^4.16.2", + "@stripe/stripe-js": "^1.54.1", "@types/crypto-js": "^4.1.1", "@types/node": "20.3.3", "@types/nodemailer": "^6.4.8", @@ -43,6 +44,7 @@ "react-image-file-resizer": "^0.4.8", "react-select": "^5.7.3", "sharp": "^0.32.1", + "stripe": "^12.13.0", "typescript": "4.9.4", "zustand": "^4.3.8" }, diff --git a/pages/api/archives/[...params].ts b/pages/api/archives/[...params].ts index 11edb4a..bfbc4a5 100644 --- a/pages/api/archives/[...params].ts +++ b/pages/api/archives/[...params].ts @@ -15,6 +15,11 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) { if (!session?.user?.username) return res.status(401).json({ response: "You must be logged in." }); + else if (session?.user?.isSubscriber === false) + res.status(401).json({ + response: + "You are not a subscriber, feel free to reach out to us at hello@linkwarden.app in case of any issues.", + }); const collectionIsAccessible = await getPermission( session.user.id, diff --git a/pages/api/auth/[...nextauth].ts b/pages/api/auth/[...nextauth].ts index fc66184..6777e1d 100644 --- a/pages/api/auth/[...nextauth].ts +++ b/pages/api/auth/[...nextauth].ts @@ -9,46 +9,45 @@ 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"; -let email; +const emailEnabled = + process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false; const providers: Provider[] = [ CredentialsProvider({ type: "credentials", - credentials: { - username: { - label: "Username", - type: "text", - }, - password: { - label: "Password", - type: "password", - }, - }, + credentials: {}, async authorize(credentials, req) { if (!credentials) return null; + const { username, password } = credentials as { + username: string; + password: string; + }; + const findUser = await prisma.user.findFirst({ - where: { - OR: [ - { - username: credentials.username.toLowerCase(), + where: emailEnabled + ? { + OR: [ + { + username: username.toLowerCase(), + }, + { + email: username?.toLowerCase(), + }, + ], + emailVerified: { not: null }, + } + : { + username: username.toLowerCase(), }, - { - email: credentials.username.toLowerCase(), - }, - ], - emailVerified: { not: null }, - }, }); let passwordMatches: boolean = false; if (findUser?.password) { - passwordMatches = bcrypt.compareSync( - credentials.password, - findUser.password - ); + passwordMatches = bcrypt.compareSync(password, findUser.password); } if (passwordMatches) { @@ -58,14 +57,13 @@ const providers: Provider[] = [ }), ]; -if (process.env.EMAIL_SERVER && process.env.EMAIL_FROM) +if (emailEnabled) providers.push( EmailProvider({ server: process.env.EMAIL_SERVER, from: process.env.EMAIL_FROM, - maxAge: 600, + maxAge: 1200, sendVerificationRequest(params) { - email = params.identifier; sendVerificationRequest(params); }, }) @@ -75,6 +73,7 @@ export const authOptions: AuthOptions = { adapter: PrismaAdapter(prisma) as Adapter, session: { strategy: "jwt", + maxAge: 30 * 24 * 60 * 60, // 30 days }, providers, pages: { @@ -85,11 +84,48 @@ export const authOptions: AuthOptions = { session: async ({ session, token }: { session: Session; token: JWT }) => { session.user.id = parseInt(token.id as string); session.user.username = token.username as string; + session.user.isSubscriber = token.isSubscriber as boolean; return session; }, // Using the `...rest` parameter to be able to narrow down the type based on `trigger` - jwt({ token, trigger, session, user }) { + async jwt({ token, trigger, session, user }) { + const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; + const PRICE_ID = process.env.PRICE_ID; + + const TRIAL_PERIOD_DAYS = process.env.TRIAL_PERIOD_DAYS; + const secondsInTwoWeeks = TRIAL_PERIOD_DAYS + ? Number(TRIAL_PERIOD_DAYS) * 86400 + : 1209600; + const subscriptionIsTimesUp = + token.subscriptionCanceledAt && + new Date() > + new Date( + ((token.subscriptionCanceledAt as number) + secondsInTwoWeeks) * + 1000 + ); + + if ( + STRIPE_SECRET_KEY && + PRICE_ID && + (trigger || subscriptionIsTimesUp || !token.isSubscriber) + ) { + console.log("EXECUTED!!!"); + const subscription = await checkSubscription( + STRIPE_SECRET_KEY, + token.email as string, + PRICE_ID + ); + + subscription.isSubscriber; + + if (subscription.subscriptionCanceledAt) { + token.subscriptionCanceledAt = subscription.subscriptionCanceledAt; + } else token.subscriptionCanceledAt = undefined; + + token.isSubscriber = subscription.isSubscriber; + } + if (trigger === "signIn") { token.id = user.id; token.username = (user as any).username; diff --git a/pages/api/auth/register.ts b/pages/api/auth/register.ts index fab6f2d..9e91bee 100644 --- a/pages/api/auth/register.ts +++ b/pages/api/auth/register.ts @@ -2,7 +2,7 @@ import { prisma } from "@/lib/api/db"; import type { NextApiRequest, NextApiResponse } from "next"; import bcrypt from "bcrypt"; -const EmailProvider = +const emailEnabled = process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false; interface Data { @@ -22,7 +22,7 @@ export default async function Index( ) { const body: User = req.body; - const checkHasEmptyFields = EmailProvider + const checkHasEmptyFields = emailEnabled ? !body.username || !body.password || !body.name || !body.email : !body.username || !body.password || !body.name; @@ -34,7 +34,7 @@ export default async function Index( const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000); // Remove user's who aren't verified for more than 10 minutes - if (EmailProvider) + if (emailEnabled) await prisma.user.deleteMany({ where: { OR: [ @@ -53,10 +53,12 @@ export default async function Index( }); const checkIfUserExists = await prisma.user.findFirst({ - where: EmailProvider + where: emailEnabled ? { OR: [ - { username: body.username.toLowerCase() }, + { + username: body.username.toLowerCase(), + }, { email: body.email?.toLowerCase(), }, diff --git a/pages/api/avatar/[id].ts b/pages/api/avatar/[id].ts index 977247b..0287229 100644 --- a/pages/api/avatar/[id].ts +++ b/pages/api/avatar/[id].ts @@ -11,17 +11,22 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) { const userName = session?.user.username?.toLowerCase(); const queryId = Number(req.query.id); - if (!queryId) - return res - .setHeader("Content-Type", "text/plain") - .status(401) - .send("Invalid parameters."); - if (!userId || !userName) return res .setHeader("Content-Type", "text/plain") .status(401) .send("You must be logged in."); + else if (session?.user?.isSubscriber === false) + res.status(401).json({ + response: + "You are not a subscriber, feel free to reach out to us at hello@linkwarden.app in case of any issues.", + }); + + if (!queryId) + return res + .setHeader("Content-Type", "text/plain") + .status(401) + .send("Invalid parameters."); if (userId !== queryId) { const targetUser = await prisma.user.findUnique({ diff --git a/pages/api/payment/index.ts b/pages/api/payment/index.ts new file mode 100644 index 0000000..66566b7 --- /dev/null +++ b/pages/api/payment/index.ts @@ -0,0 +1,26 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/pages/api/auth/[...nextauth]"; +import paymentCheckout from "@/lib/api/paymentCheckout"; + +export default async function users(req: NextApiRequest, res: NextApiResponse) { + const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; + const PRICE_ID = process.env.PRICE_ID; + const session = await getServerSession(req, res, authOptions); + + if (!session?.user?.username) + return res.status(401).json({ response: "You must be logged in." }); + else if (!STRIPE_SECRET_KEY || !PRICE_ID) { + return res.status(400).json({ response: "Payment is disabled." }); + } + + if (req.method === "GET") { + const users = await paymentCheckout( + STRIPE_SECRET_KEY, + session?.user.email, + "register", + PRICE_ID + ); + return res.status(users.status).json({ response: users.response }); + } +} diff --git a/pages/api/routes/collections/index.ts b/pages/api/routes/collections/index.ts index 4d83918..25fec73 100644 --- a/pages/api/routes/collections/index.ts +++ b/pages/api/routes/collections/index.ts @@ -1,6 +1,6 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { getServerSession } from "next-auth/next"; -import { authOptions } from "pages/api/auth/[...nextauth]"; +import { authOptions } from "@/pages/api/auth/[...nextauth]"; import getCollections from "@/lib/api/controllers/collections/getCollections"; import postCollection from "@/lib/api/controllers/collections/postCollection"; import updateCollection from "@/lib/api/controllers/collections/updateCollection"; @@ -14,7 +14,11 @@ export default async function collections( if (!session?.user?.username) { return res.status(401).json({ response: "You must be logged in." }); - } + } else if (session?.user?.isSubscriber === false) + res.status(401).json({ + response: + "You are not a subscriber, feel free to reach out to us at hello@linkwarden.app in case of any issues.", + }); if (req.method === "GET") { const collections = await getCollections(session.user.id); diff --git a/pages/api/routes/links/index.ts b/pages/api/routes/links/index.ts index 7306de8..4a226d4 100644 --- a/pages/api/routes/links/index.ts +++ b/pages/api/routes/links/index.ts @@ -1,6 +1,6 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { getServerSession } from "next-auth/next"; -import { authOptions } from "pages/api/auth/[...nextauth]"; +import { authOptions } from "@/pages/api/auth/[...nextauth]"; import getLinks from "@/lib/api/controllers/links/getLinks"; import postLink from "@/lib/api/controllers/links/postLink"; import deleteLink from "@/lib/api/controllers/links/deleteLink"; @@ -11,7 +11,11 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) { if (!session?.user?.username) { return res.status(401).json({ response: "You must be logged in." }); - } + } else if (session?.user?.isSubscriber === false) + res.status(401).json({ + response: + "You are not a subscriber, feel free to reach out to us at hello@linkwarden.app in case of any issues.", + }); if (req.method === "GET") { const links = await getLinks(session.user.id, req?.query?.body as string); diff --git a/pages/api/routes/tags/index.ts b/pages/api/routes/tags/index.ts index 482cd69..0df83a8 100644 --- a/pages/api/routes/tags/index.ts +++ b/pages/api/routes/tags/index.ts @@ -1,6 +1,6 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { getServerSession } from "next-auth/next"; -import { authOptions } from "pages/api/auth/[...nextauth]"; +import { authOptions } from "@/pages/api/auth/[...nextauth]"; import getTags from "@/lib/api/controllers/tags/getTags"; export default async function tags(req: NextApiRequest, res: NextApiResponse) { @@ -8,7 +8,11 @@ export default async function tags(req: NextApiRequest, res: NextApiResponse) { if (!session?.user?.username) { return res.status(401).json({ response: "You must be logged in." }); - } + } else if (session?.user?.isSubscriber === false) + res.status(401).json({ + response: + "You are not a subscriber, feel free to reach out to us at hello@linkwarden.app in case of any issues.", + }); if (req.method === "GET") { const tags = await getTags(session.user.id); diff --git a/pages/api/routes/users/index.ts b/pages/api/routes/users/index.ts index a64b630..6757bab 100644 --- a/pages/api/routes/users/index.ts +++ b/pages/api/routes/users/index.ts @@ -1,6 +1,6 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { getServerSession } from "next-auth/next"; -import { authOptions } from "pages/api/auth/[...nextauth]"; +import { authOptions } from "@/pages/api/auth/[...nextauth]"; import getUsers from "@/lib/api/controllers/users/getUsers"; import updateUser from "@/lib/api/controllers/users/updateUser"; @@ -9,16 +9,35 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) { if (!session?.user.username) { return res.status(401).json({ response: "You must be logged in." }); - } + } else if (session?.user?.isSubscriber === false) + res.status(401).json({ + response: + "You are not a subscriber, feel free to reach out to us at hello@linkwarden.app in case of any issues.", + }); - const lookupUsername = req.query.username as string; + const lookupUsername = (req.query.username as string) || undefined; + const lookupId = Number(req.query.id) || undefined; const isSelf = session.user.username === lookupUsername ? true : false; if (req.method === "GET") { - const users = await getUsers(lookupUsername, isSelf, session.user.username); + const users = await getUsers({ + params: { + lookupUsername, + lookupId, + }, + isSelf, + username: session.user.username, + }); return res.status(users.status).json({ response: users.response }); } else if (req.method === "PUT" && !req.body.password) { - const updated = await updateUser(req.body, session.user.id); + const updated = await updateUser(req.body, session.user); return res.status(updated.status).json({ response: updated.response }); } } + +// { +// lookupUsername, +// lookupId, +// }, +// isSelf, +// session.user.username diff --git a/pages/collections/index.tsx b/pages/collections/index.tsx index 6c7c526..2bd5c8d 100644 --- a/pages/collections/index.tsx +++ b/pages/collections/index.tsx @@ -105,7 +105,7 @@ export default function Collections() { -
+
{sortedCollections.map((e, i) => { return ; })} diff --git a/pages/dashboard.tsx b/pages/dashboard.tsx index 4f0e973..9e721ff 100644 --- a/pages/dashboard.tsx +++ b/pages/dashboard.tsx @@ -107,14 +107,18 @@ export default function Dashboard() {

{collections.length}

-

Collections

+

+ {collections.length === 1 ? "Collection" : "Collections"} +

{tags.length}

-

Tags

+

+ {tags.length === 1 ? "Tag" : "Tags"} +

@@ -122,45 +126,57 @@ export default function Dashboard() {
- -
- { - setLinkPinDisclosure(!linkPinDisclosure); - }} - className="flex justify-between gap-2 items-baseline shadow active:shadow-inner duration-100 py-2 px-4 rounded-full" - > -

Pinned Links

+ {links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? ( + +
+ { + setLinkPinDisclosure(!linkPinDisclosure); + }} + className="flex justify-between gap-2 items-baseline shadow active:shadow-inner duration-100 py-2 px-4 rounded-full" + > +

Pinned Links

-
- {linkPinDisclosure ? "Hide" : "Show"} - -
-
+
+ {linkPinDisclosure ? "Hide" : "Show"} + +
+ - - - {links - .filter((e) => e.pinnedBy && e.pinnedBy[0]) - .map((e, i) => ( - - ))} - - + + + {links + .filter((e) => e.pinnedBy && e.pinnedBy[0]) + .map((e, i) => ( + + ))} + + +
+
+ ) : ( +
+

+ No Pinned Links +

+

+ You can Pin Links by clicking on the three dots on each Link and + clicking "Pin to Dashboard." +

- + )} {/*
diff --git a/pages/forgot.tsx b/pages/forgot.tsx index 5d5557d..2b1f23d 100644 --- a/pages/forgot.tsx +++ b/pages/forgot.tsx @@ -39,7 +39,7 @@ export default function Forgot() { return ( <> -
+
-

Password Reset

+

Password Reset

diff --git a/pages/login.tsx b/pages/login.tsx index 24d7412..f17798c 100644 --- a/pages/login.tsx +++ b/pages/login.tsx @@ -10,7 +10,7 @@ interface FormData { password: string; } -const EmailProvider = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; +const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; export default function Login() { const [submitLoader, setSubmitLoader] = useState(false); @@ -46,7 +46,7 @@ export default function Login() { return ( <> -
+
-

Welcome back

+

Welcome back

Sign in to your account

@@ -66,7 +66,7 @@ export default function Login() {

Username - {EmailProvider ? "/Email" : undefined} + {emailEnabled ? "/Email" : undefined}

setForm({ ...form, password: e.target.value })} className="w-full rounded-md p-2 mx-auto border-sky-100 border-solid border outline-none focus:border-sky-500 duration-100" /> - {EmailProvider && ( + {emailEnabled && (
Forgot Password? diff --git a/pages/register.tsx b/pages/register.tsx index 60ee1fe..2edada5 100644 --- a/pages/register.tsx +++ b/pages/register.tsx @@ -5,7 +5,7 @@ import SubmitButton from "@/components/SubmitButton"; import { signIn } from "next-auth/react"; import Image from "next/image"; -const EmailProvider = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; +const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; type FormData = { name: string; @@ -21,14 +21,14 @@ export default function Register() { const [form, setForm] = useState({ name: "", username: "", - email: EmailProvider ? "" : undefined, + email: emailEnabled ? "" : undefined, password: "", passwordConfirmation: "", }); async function registerUser() { const checkHasEmptyFields = () => { - if (EmailProvider) { + if (emailEnabled) { return ( form.name !== "" && form.username !== "" && @@ -77,11 +77,7 @@ export default function Register() { if (response.ok) { if (form.email) await sendConfirmation(); - toast.success( - EmailProvider - ? "User Created! Please check you email." - : "User Created!" - ); + toast.success("User Created!"); } else { toast.error(data.response); } @@ -95,7 +91,7 @@ export default function Register() { return ( <> -
+
-

Get started

+

Get started

Create a new account

@@ -140,7 +136,7 @@ export default function Register() { />
- {EmailProvider ? ( + {emailEnabled ? (

Email @@ -156,29 +152,29 @@ export default function Register() {

) : undefined} -
-
+
+

Password

setForm({ ...form, password: e.target.value })} className="w-full rounded-md p-2 mx-auto border-sky-100 border-solid border outline-none focus:border-sky-500 duration-100" />
-
+

Confirm Password

setForm({ ...form, passwordConfirmation: e.target.value }) diff --git a/pages/subscribe.tsx b/pages/subscribe.tsx new file mode 100644 index 0000000..4d4697b --- /dev/null +++ b/pages/subscribe.tsx @@ -0,0 +1,79 @@ +import SubmitButton from "@/components/SubmitButton"; +import { signOut } from "next-auth/react"; +import Image from "next/image"; +import { useEffect, useState } from "react"; +import { toast } from "react-hot-toast"; +import { useSession } from "next-auth/react"; +import { useRouter } from "next/router"; + +export default function Subscribe() { + const [submitLoader, setSubmitLoader] = useState(false); + + const { data, status } = useSession(); + const router = useRouter(); + + useEffect(() => { + console.log(data?.user); + }, [status]); + + async function loginUser() { + setSubmitLoader(true); + + const redirectionToast = toast.loading("Redirecting to Stripe..."); + + const res = await fetch("/api/payment"); + const data = await res.json(); + + console.log(data); + router.push(data.response); + } + + return ( + <> +
+
+ Linkwarden +
+

14 days free trial

+

+ Then $5/month afterwards +

+
+
+ +
+

+ You will be redirected to Stripe. +

+

+ feel free to reach out to us at{" "} + + hello@linkwarden.app + {" "} + in case of any issues. +

+
+ + + +
signOut()} + className="w-fit mx-auto cursor-pointer text-gray-500 font-semibold " + > + Sign Out +
+
+ + ); +} diff --git a/prisma/migrations/20230711222012_init/migration.sql b/prisma/migrations/20230715102805_init/migration.sql similarity index 100% rename from prisma/migrations/20230711222012_init/migration.sql rename to prisma/migrations/20230715102805_init/migration.sql diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 13aec07..a3882aa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -35,12 +35,12 @@ model Session { } model User { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) name String username String @unique - email String? @unique + email String? @unique emailVerified DateTime? image String? @@ -60,7 +60,6 @@ model User { createdAt DateTime @default(now()) } - model VerificationToken { identifier String token String @unique @@ -69,7 +68,6 @@ model VerificationToken { @@unique([identifier, token]) } - model Collection { id Int @id @default(autoincrement()) name String diff --git a/types/enviornment.d.ts b/types/enviornment.d.ts index 62a004b..0f32eb7 100644 --- a/types/enviornment.d.ts +++ b/types/enviornment.d.ts @@ -16,6 +16,11 @@ declare global { NEXT_PUBLIC_EMAIL_PROVIDER?: true; EMAIL_FROM?: string; EMAIL_SERVER?: string; + + STRIPE_SECRET_KEY?: string; + PRICE_ID?: string; + NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL?: string; + TRIAL_PERIOD_DAYS?: string; } } } diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts index ca9c127..6df7f6c 100644 --- a/types/next-auth.d.ts +++ b/types/next-auth.d.ts @@ -6,6 +6,7 @@ declare module "next-auth" { id: number; username: string; email: string; + isSubscriber: boolean; }; } } diff --git a/yarn.lock b/yarn.lock index d6e0f86..532967f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1470,6 +1470,11 @@ "@smithy/types" "^1.1.0" tslib "^2.5.0" +"@stripe/stripe-js@^1.54.1": + version "1.54.1" + resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.54.1.tgz#e298b80c2963d9e622ea355db6c35df48e08cd89" + integrity sha512-smEXPu1GKMcAj9g2luT16+oXfg2jAwyc68t2Dm5wdtYl3p8PqQaZEiI8tQmboaQAjgF8pIGma6byz1T1vgmpbA== + "@swc/helpers@0.4.14": version "0.4.14" resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.14.tgz#1352ac6d95e3617ccb7c1498ff019654f1e12a74" @@ -1499,6 +1504,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-20.3.3.tgz#329842940042d2b280897150e023e604d11657d6" integrity sha512-wheIYdr4NYML61AjC8MKj/2jrR/kDQri/CIpVoZwldwhnIrD/j9jIU5bJ8yBKuB2VhpFV7Ab6G2XkBjv9r9Zzw== +"@types/node@>=8.1.0": + version "20.4.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.4.2.tgz#129cc9ae69f93824f92fac653eebfb4812ab4af9" + integrity sha512-Dd0BYtWgnWJKwO1jkmTrzofjK2QXXcai0dmtzvIBhcA+RsG5h8R3xlyta0kGOZRNfL9GuRtb1knmPEhQrePCEw== + "@types/nodemailer@^6.4.8": version "6.4.8" resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.8.tgz#f06c661e9b201fc2acc3a00a0fded42ba7eaca9d" @@ -4061,6 +4071,13 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f" integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== +qs@^6.11.0: + version "6.11.2" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.2.tgz#64bea51f12c1f5da1bc01496f48ffcff7c69d7d9" + integrity sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA== + dependencies: + side-channel "^1.0.4" + qs@~6.5.2: version "6.5.3" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.3.tgz#3aeeffc91967ef6e35c0e488ef46fb296ab76aad" @@ -4482,6 +4499,14 @@ strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== +stripe@^12.13.0: + version "12.13.0" + resolved "https://registry.yarnpkg.com/stripe/-/stripe-12.13.0.tgz#7a8b5705a6f633384e901f512fe1a834277f3123" + integrity sha512-mn7CxL71FCRWkQp33jcJ7+xfRF7HGzPYZlq2c87U+6kxL1qd7f/N3S1g1E5uaSWe83V5v3jN/IiWqg9y8+kWRw== + dependencies: + "@types/node" ">=8.1.0" + qs "^6.11.0" + strnum@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.0.5.tgz#5c4e829fe15ad4ff0d20c3db5ac97b73c9b072db"