From f82582a0bd8406f8c49306f217661f2751c9da77 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 15 Jul 2023 22:15:43 -0400 Subject: [PATCH 01/10] implemented stripe for the cloud instance --- .env.sample | 7 +- components/Modal/User/ChangePassword.tsx | 4 +- components/Modal/User/ProfileSettings.tsx | 4 +- components/Sidebar.tsx | 6 +- layouts/AuthRedirect.tsx | 9 +- layouts/MainLayout.tsx | 4 +- lib/api/checkSubscription.ts | 50 +++++++++++ lib/api/controllers/users/updateUser.ts | 25 +++++- lib/api/paymentCheckout.ts | 73 +++++++++++++++ lib/api/updateCustomerEmail.ts | 47 ++++++++++ package.json | 2 + pages/api/archives/[...params].ts | 5 ++ pages/api/auth/[...nextauth].ts | 89 +++++++++++++------ pages/api/auth/register.ts | 12 +-- pages/api/avatar/[id].ts | 17 ++-- pages/api/payment/index.ts | 26 ++++++ pages/api/routes/collections/index.ts | 8 +- pages/api/routes/links/index.ts | 8 +- pages/api/routes/tags/index.ts | 8 +- pages/api/routes/users/index.ts | 10 ++- pages/forgot.tsx | 4 +- pages/login.tsx | 12 +-- pages/register.tsx | 24 ++--- pages/subscribe.tsx | 72 +++++++++++++++ .../migration.sql | 0 prisma/schema.prisma | 6 +- types/enviornment.d.ts | 4 + types/next-auth.d.ts | 1 + yarn.lock | 25 ++++++ 29 files changed, 474 insertions(+), 88 deletions(-) create mode 100644 lib/api/checkSubscription.ts create mode 100644 lib/api/paymentCheckout.ts create mode 100644 lib/api/updateCustomerEmail.ts create mode 100644 pages/api/payment/index.ts create mode 100644 pages/subscribe.tsx rename prisma/migrations/{20230711222012_init => 20230715102805_init}/migration.sql (100%) diff --git a/.env.sample b/.env.sample index e6faff3..7f4e76d 100644 --- a/.env.sample +++ b/.env.sample @@ -15,4 +15,9 @@ 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= +STRIPE_BILLING_PORTAL_URL= \ No newline at end of file diff --git a/components/Modal/User/ChangePassword.tsx b/components/Modal/User/ChangePassword.tsx index 55e8c60..6237291 100644 --- a/components/Modal/User/ChangePassword.tsx +++ b/components/Modal/User/ChangePassword.tsx @@ -80,7 +80,7 @@ export default function ChangePassword({ value={newPassword} onChange={(e) => setNewPassword1(e.target.value)} type="password" - placeholder="***********" + placeholder="••••••••••••••" className="w-full rounded-md p-3 mx-auto border-sky-100 border-solid border outline-none focus:border-sky-500 duration-100" />

Confirm New Password

@@ -89,7 +89,7 @@ export default function ChangePassword({ value={newPassword2} onChange={(e) => setNewPassword2(e.target.value)} type="password" - placeholder="***********" + placeholder="••••••••••••••" className="w-full rounded-md p-3 mx-auto border-sky-100 border-solid border outline-none focus:border-sky-500 duration-100" /> diff --git a/components/Modal/User/ProfileSettings.tsx b/components/Modal/User/ProfileSettings.tsx index 19d364f..2a705db 100644 --- a/components/Modal/User/ProfileSettings.tsx +++ b/components/Modal/User/ProfileSettings.tsx @@ -16,7 +16,7 @@ type Props = { user: AccountSettings; }; -const EmailProvider = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; +const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; export default function ProfileSettings({ toggleSettingsModal, @@ -165,7 +165,7 @@ export default function ProfileSettings({ /> - {EmailProvider ? ( + {emailEnabled ? (

Email

-

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..6bcc600 100644 --- a/layouts/MainLayout.tsx +++ b/layouts/MainLayout.tsx @@ -1,6 +1,6 @@ import Navbar from "@/components/Navbar"; import Sidebar from "@/components/Sidebar"; -import { ReactNode, useEffect } from "react"; +import { ReactNode } from "react"; import { useSession } from "next-auth/react"; import Loader from "../components/Loader"; import useRedirect from "@/hooks/useRedirect"; @@ -12,7 +12,7 @@ interface Props { } 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; diff --git a/lib/api/checkSubscription.ts b/lib/api/checkSubscription.ts new file mode 100644 index 0000000..4734566 --- /dev/null +++ b/lib/api/checkSubscription.ts @@ -0,0 +1,50 @@ +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 secondsInTwoWeeks = 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/users/updateUser.ts b/lib/api/controllers/users/updateUser.ts index 62fcf3e..f6f8131 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 { @@ -24,7 +30,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 +45,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 +55,7 @@ export default async function updateUser( const updatedUser = await prisma.user.update({ where: { - id: userId, + id: sessionUser.id, }, data: { name: user.name, @@ -64,6 +70,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..5cda18d --- /dev/null +++ b/lib/api/paymentCheckout.ts @@ -0,0 +1,73 @@ +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 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: 14, + }, + }); + + return { response: session.url, status: 200 }; +} diff --git a/lib/api/updateCustomerEmail.ts b/lib/api/updateCustomerEmail.ts new file mode 100644 index 0000000..0254d88 --- /dev/null +++ b/lib/api/updateCustomerEmail.ts @@ -0,0 +1,47 @@ +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 secondsInTwoWeeks = 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/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..1aa4ca2 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,43 @@ 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; + + console.log("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaa", token); + + const secondsInTwoWeeks = 1209600; + const subscriptionIsTimesUp = + token.subscriptionCanceledAt && + new Date() > + new Date( + ((token.subscriptionCanceledAt as number) + secondsInTwoWeeks) * + 1000 + ); + + if (STRIPE_SECRET_KEY && PRICE_ID && (trigger || subscriptionIsTimesUp)) { + 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..91b2bda 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,7 +9,11 @@ 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 isSelf = session.user.username === lookupUsername ? true : false; @@ -18,7 +22,7 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) { const users = await getUsers(lookupUsername, isSelf, 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 }); } } 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..84a0844 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 !== "" && @@ -78,7 +78,7 @@ export default function Register() { if (form.email) await sendConfirmation(); toast.success( - EmailProvider + emailEnabled ? "User Created! Please check you email." : "User Created!" ); @@ -95,7 +95,7 @@ export default function Register() { return ( <> -
+
-

Get started

+

Get started

Create a new account

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

Email @@ -156,29 +156,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..ba1223a --- /dev/null +++ b/pages/subscribe.tsx @@ -0,0 +1,72 @@ +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. +

+
+ + + +
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..9b5619a 100644 --- a/types/enviornment.d.ts +++ b/types/enviornment.d.ts @@ -16,6 +16,10 @@ declare global { NEXT_PUBLIC_EMAIL_PROVIDER?: true; EMAIL_FROM?: string; EMAIL_SERVER?: string; + + STRIPE_SECRET_KEY?: string; + PRICE_ID?: string; + STRIPE_BILLING_PORTAL_URL?: 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" From c7cfbc38467ea8413b97fd37c0b648be9b91eff1 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 16 Jul 2023 00:18:49 -0400 Subject: [PATCH 02/10] added billing portal button --- .env.sample | 2 +- components/Modal/User/BillingPortal.tsx | 43 +++++++++++++++++++++++++ components/Modal/User/index.tsx | 22 +++++++++++++ pages/subscribe.tsx | 7 ++++ types/enviornment.d.ts | 2 +- 5 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 components/Modal/User/BillingPortal.tsx diff --git a/.env.sample b/.env.sample index 7f4e76d..d13c0e5 100644 --- a/.env.sample +++ b/.env.sample @@ -20,4 +20,4 @@ EMAIL_SERVER= # Stripe settings (You don't need these, it's for the cloud instance payments) STRIPE_SECRET_KEY= PRICE_ID= -STRIPE_BILLING_PORTAL_URL= \ No newline at end of file +NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL= \ No newline at end of file diff --git a/components/Modal/User/BillingPortal.tsx b/components/Modal/User/BillingPortal.tsx new file mode 100644 index 0000000..2edc516 --- /dev/null +++ b/components/Modal/User/BillingPortal.tsx @@ -0,0 +1,43 @@ +import { useState } from "react"; +import SubmitButton from "@/components/SubmitButton"; +import { toast } from "react-hot-toast"; +import { useRouter } from "next/router"; +import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; + +export default function PaymentPortal() { + const [submitLoader, setSubmitLoader] = useState(false); + const router = useRouter(); + + const submit = () => { + setSubmitLoader(true); + const load = toast.loading("Redirecting to billing portal..."); + + router.push(process.env.NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL as string); + }; + + return ( +
+
+

+ To manage/cancel your subsciption, visit the billing portal. +

+ + + +

+ If you still need help or encountered any issues, feel free to reach + out to us at:{" "} + + hello@linkwarden.app + +

+
+
+ ); +} diff --git a/components/Modal/User/index.tsx b/components/Modal/User/index.tsx index 41ea8b9..199d9bb 100644 --- a/components/Modal/User/index.tsx +++ b/components/Modal/User/index.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import ChangePassword from "./ChangePassword"; import ProfileSettings from "./ProfileSettings"; import PrivacySettings from "./PrivacySettings"; +import BillingPortal from "./BillingPortal"; type Props = { toggleSettingsModal: Function; @@ -12,6 +13,9 @@ type Props = { defaultIndex?: number; }; +const STRIPE_BILLING_PORTAL_URL = + process.env.NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL; + export default function UserModal({ className, defaultIndex, @@ -53,6 +57,18 @@ export default function UserModal({ > 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/pages/subscribe.tsx b/pages/subscribe.tsx index ba1223a..4d4697b 100644 --- a/pages/subscribe.tsx +++ b/pages/subscribe.tsx @@ -51,6 +51,13 @@ export default function Subscribe() {

You will be redirected to Stripe.

+

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

Date: Sun, 16 Jul 2023 11:51:24 -0400 Subject: [PATCH 03/10] bug fixes + improvements --- .env.sample | 1 + components/Modal/User/ProfileSettings.tsx | 4 +++- layouts/MainLayout.tsx | 11 ++++++++++- lib/api/checkSubscription.ts | 5 ++++- lib/api/paymentCheckout.ts | 3 ++- lib/api/updateCustomerEmail.ts | 5 ++++- pages/api/auth/[...nextauth].ts | 13 +++++++++---- pages/register.tsx | 6 +----- types/enviornment.d.ts | 1 + 9 files changed, 35 insertions(+), 14 deletions(-) diff --git a/.env.sample b/.env.sample index d13c0e5..3413f7c 100644 --- a/.env.sample +++ b/.env.sample @@ -20,4 +20,5 @@ 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/Modal/User/ProfileSettings.tsx b/components/Modal/User/ProfileSettings.tsx index 2a705db..0365a78 100644 --- a/components/Modal/User/ProfileSettings.tsx +++ b/components/Modal/User/ProfileSettings.tsx @@ -3,7 +3,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faClose } from "@fortawesome/free-solid-svg-icons"; import useAccountStore from "@/store/account"; import { AccountSettings } from "@/types/global"; -import { useSession } from "next-auth/react"; +import { signOut, useSession } from "next-auth/react"; import { resizeImage } from "@/lib/client/resizeImage"; import { faPenToSquare } from "@fortawesome/free-regular-svg-icons"; import SubmitButton from "../../SubmitButton"; @@ -93,6 +93,8 @@ export default function ProfileSettings({ name: user.name, }); + signOut(); + if (response.ok) { setUser({ ...user, newPassword: undefined }); toggleSettingsModal(); diff --git a/layouts/MainLayout.tsx b/layouts/MainLayout.tsx index 6bcc600..de0dea9 100644 --- a/layouts/MainLayout.tsx +++ b/layouts/MainLayout.tsx @@ -1,11 +1,12 @@ import Navbar from "@/components/Navbar"; import Sidebar from "@/components/Sidebar"; -import { ReactNode } from "react"; +import { ReactNode, useEffect } from "react"; import { useSession } from "next-auth/react"; 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; @@ -17,6 +18,14 @@ export default function MainLayout({ children }: Props) { 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 index 4734566..67f001c 100644 --- a/lib/api/checkSubscription.ts +++ b/lib/api/checkSubscription.ts @@ -19,7 +19,10 @@ export default async function checkSubscription( const isSubscriber = listByEmail.data.some((customer, i) => { const hasValidSubscription = customer.subscriptions?.data.some( (subscription) => { - const secondsInTwoWeeks = 1209600; + const TRIAL_PERIOD_DAYS = process.env.TRIAL_PERIOD_DAYS; + const secondsInTwoWeeks = TRIAL_PERIOD_DAYS + ? Number(TRIAL_PERIOD_DAYS) * 86400 + : 1209600; subscriptionCanceledAt = subscription.canceled_at; diff --git a/lib/api/paymentCheckout.ts b/lib/api/paymentCheckout.ts index 5cda18d..63dac04 100644 --- a/lib/api/paymentCheckout.ts +++ b/lib/api/paymentCheckout.ts @@ -49,6 +49,7 @@ export default async function paymentCheckout( // ); // } + const TRIAL_PERIOD_DAYS = process.env.TRIAL_PERIOD_DAYS; const session = await stripe.checkout.sessions.create({ customer: isExistingCostomer ? isExistingCostomer : undefined, line_items: [ @@ -65,7 +66,7 @@ export default async function paymentCheckout( enabled: true, }, subscription_data: { - trial_period_days: 14, + trial_period_days: TRIAL_PERIOD_DAYS ? Number(TRIAL_PERIOD_DAYS) : 14, }, }); diff --git a/lib/api/updateCustomerEmail.ts b/lib/api/updateCustomerEmail.ts index 0254d88..07af401 100644 --- a/lib/api/updateCustomerEmail.ts +++ b/lib/api/updateCustomerEmail.ts @@ -18,7 +18,10 @@ export default async function updateCustomerEmail( const customer = listByEmail.data.find((customer, i) => { const hasValidSubscription = customer.subscriptions?.data.some( (subscription) => { - const secondsInTwoWeeks = 1209600; + 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 && diff --git a/pages/api/auth/[...nextauth].ts b/pages/api/auth/[...nextauth].ts index 1aa4ca2..6777e1d 100644 --- a/pages/api/auth/[...nextauth].ts +++ b/pages/api/auth/[...nextauth].ts @@ -93,9 +93,10 @@ export const authOptions: AuthOptions = { const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; const PRICE_ID = process.env.PRICE_ID; - console.log("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaa", token); - - const secondsInTwoWeeks = 1209600; + 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() > @@ -104,7 +105,11 @@ export const authOptions: AuthOptions = { 1000 ); - if (STRIPE_SECRET_KEY && PRICE_ID && (trigger || subscriptionIsTimesUp)) { + if ( + STRIPE_SECRET_KEY && + PRICE_ID && + (trigger || subscriptionIsTimesUp || !token.isSubscriber) + ) { console.log("EXECUTED!!!"); const subscription = await checkSubscription( STRIPE_SECRET_KEY, diff --git a/pages/register.tsx b/pages/register.tsx index 84a0844..2edada5 100644 --- a/pages/register.tsx +++ b/pages/register.tsx @@ -77,11 +77,7 @@ export default function Register() { if (response.ok) { if (form.email) await sendConfirmation(); - toast.success( - emailEnabled - ? "User Created! Please check you email." - : "User Created!" - ); + toast.success("User Created!"); } else { toast.error(data.response); } diff --git a/types/enviornment.d.ts b/types/enviornment.d.ts index 522221f..0f32eb7 100644 --- a/types/enviornment.d.ts +++ b/types/enviornment.d.ts @@ -20,6 +20,7 @@ declare global { STRIPE_SECRET_KEY?: string; PRICE_ID?: string; NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL?: string; + TRIAL_PERIOD_DAYS?: string; } } } From 46cca3cff3a7b22eb62984933bee205169497e51 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 16 Jul 2023 12:44:34 -0400 Subject: [PATCH 04/10] bug fix --- components/Modal/User/ProfileSettings.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/components/Modal/User/ProfileSettings.tsx b/components/Modal/User/ProfileSettings.tsx index 0365a78..1591738 100644 --- a/components/Modal/User/ProfileSettings.tsx +++ b/components/Modal/User/ProfileSettings.tsx @@ -86,14 +86,15 @@ export default function ProfileSettings({ user.username !== account.username || user.name !== account.name || user.email !== account.email - ) + ) { update({ username: user.username, email: user.username, name: user.name, }); - signOut(); + signOut(); + } if (response.ok) { setUser({ ...user, newPassword: undefined }); From 78f3d449bc01138f82063a4d17dfad1f8f77da40 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 17 Jul 2023 23:31:26 -0400 Subject: [PATCH 05/10] collection owner is now visible to other users --- .../Modal/Collection/TeamManagement.tsx | 47 +++++++++++++++++-- lib/api/controllers/users/getUsers.ts | 20 +++++--- lib/client/addMemberToCollection.ts | 8 ++-- lib/client/getPublicUserData.ts | 21 +++++++++ lib/client/getPublicUserDataByUsername.ts | 13 ----- pages/api/routes/users/index.ts | 19 +++++++- 6 files changed, 100 insertions(+), 28 deletions(-) create mode 100644 lib/client/getPublicUserData.ts delete mode 100644 lib/client/getPublicUserDataByUsername.ts diff --git a/components/Modal/Collection/TeamManagement.tsx b/components/Modal/Collection/TeamManagement.tsx index 6874603..57d42ed 100644 --- a/components/Modal/Collection/TeamManagement.tsx +++ b/components/Modal/Collection/TeamManagement.tsx @@ -1,7 +1,8 @@ -import { Dispatch, SetStateAction, useState } from "react"; +import { Dispatch, SetStateAction, useEffect, useState } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faClose, + faCrown, faPenToSquare, faPlus, faUserPlus, @@ -15,6 +16,7 @@ import SubmitButton from "@/components/SubmitButton"; import ProfilePhoto from "@/components/ProfilePhoto"; import usePermissions from "@/hooks/usePermissions"; import { toast } from "react-hot-toast"; +import getPublicUserData from "@/lib/client/getPublicUserData"; type Props = { toggleCollectionModal: Function; @@ -47,6 +49,21 @@ export default function TeamManagement({ }, }); + const [collectionOwner, setCollectionOwner] = useState({ + id: null, + name: "", + username: "", + }); + + useEffect(() => { + const fetchOwner = async () => { + const owner = await getPublicUserData({ id: collection.ownerId }); + setCollectionOwner(owner); + }; + + fetchOwner(); + }, []); + const { addCollection, updateCollection } = useCollectionStore(); const session = useSession(); @@ -163,7 +180,7 @@ export default function TeamManagement({ ) } type="text" - placeholder="Username" + placeholder="Username (without the '@')" className="w-full rounded-md p-3 border-sky-100 border-solid border outline-none focus:border-sky-500 duration-100" /> @@ -225,7 +242,7 @@ export default function TeamManagement({

{e.user.name}

-

{e.user.username}

+

@{e.user.username}

@@ -397,6 +414,30 @@ export default function TeamManagement({ )} +
+
+ +
+
+

+ {collectionOwner.name} +

+ +
+

@{collectionOwner.username}

+
+
+
+ {permissions === true && ( Date: Tue, 18 Jul 2023 11:34:43 -0400 Subject: [PATCH 06/10] small styling improvement --- pages/dashboard.tsx | 92 ++++++++++++++++++++++++++------------------- 1 file changed, 54 insertions(+), 38 deletions(-) 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." +

- + )} {/*
From b07040a26f9ddf6db84b81cd15eb58a54cb53f7b Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 18 Jul 2023 11:59:57 -0400 Subject: [PATCH 07/10] small improvements --- components/Modal/Link/LinkDetails.tsx | 2 +- lib/api/controllers/links/postLink.ts | 5 ++++- lib/api/storage/createFolder.ts | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/components/Modal/Link/LinkDetails.tsx b/components/Modal/Link/LinkDetails.tsx index c374aec..3d18233 100644 --- a/components/Modal/Link/LinkDetails.tsx +++ b/components/Modal/Link/LinkDetails.tsx @@ -131,7 +131,7 @@ export default function LinkDetails({ link }: Props) { height={42} alt="" id={"favicon-" + link.id} - className="select-none mt-2 rounded-full shadow border-[3px] border-white bg-white aspect-square" + className="select-none mt-2 rounded-md shadow border-[3px] border-white bg-white aspect-square" draggable="false" onLoad={(e) => { try { diff --git a/lib/api/controllers/links/postLink.ts b/lib/api/controllers/links/postLink.ts index 940e318..ed04a89 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 = "Other"; } 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/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); From 11152caf6d4bf889bb2fa33f3f2c39c933836a9c Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 18 Jul 2023 12:06:47 -0400 Subject: [PATCH 08/10] minor UX improvement --- components/InputSelect/CollectionSelection.tsx | 1 + components/Modal/Link/AddOrEditLink.tsx | 5 +---- lib/api/controllers/links/postLink.ts | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) 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 (