diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 57efadbf..00000000 --- a/.eslintrc +++ /dev/null @@ -1,23 +0,0 @@ -{ - "parser": "@typescript-eslint/parser", - "parserOptions": { - "project": "./tsconfig.json" - }, - "plugins": ["@typescript-eslint", "jsx-a11y"], - "extends": [ - "next/core-web-vitals", - "plugin:@typescript-eslint/recommended", - "prettier", - "plugin:jsx-a11y/recommended", - "plugin:playwright/recommended" - ], - "rules": { - "@typescript-eslint/consistent-type-imports": "warn", - "@typescript-eslint/ban-ts-comment": "warn", - "@next/next/no-img-element": "off", - "no-unused-vars": "warn", - "@typescript-eslint/no-unused-vars": "warn", - "no-console": "warn" - }, - "ignorePatterns": ["next.config.js", "*/lambdas/**/*.js"] -} diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 90250dc2..c97d5a06 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -27,7 +27,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "lts/*" + node-version-file: '.nvmrc' cache: "npm" - name: Install dependencies @@ -55,7 +55,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "lts/*" + node-version-file: '.nvmrc' cache: "npm" - name: Cache Playwright browsers diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 84df7d17..41990f6d 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -19,7 +19,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: 'lts/*' + node-version-file: '.nvmrc' cache: 'npm' - name: Install dependencies diff --git a/.gitignore b/.gitignore index d3f19f80..2951c3a5 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,6 @@ ssmSetup.zsh # open-next .open-next + +# Snyk Security Extension - AI Rules (auto-generated) +.github/instructions/snyk_rules.instructions.md diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..a86d7c2c --- /dev/null +++ b/.mcp.json @@ -0,0 +1,27 @@ +{ + "mcpServers": { + "context7": { + "command": "npx", + "args": ["-y", "@upstash/context7-mcp"], + "env": { + "CONTEXT7_API_KEY": "ctx7sk-1d829fe1-62b2-4697-b7f4-673ae5047efd" + } + }, + "puppeteer": { + "command": "npx", + "args": ["-y", "puppeteer-mcp-server"], + "env": {} + }, + "next-devtools": { + "command": "npx", + "args": ["-y", "next-devtools-mcp@latest"] + }, + "sequential-thinking": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"] + }, + "Sentry": { + "url": "https://mcp.sentry.dev/mcp/assemble-pro/javascript-nextjs" + } + } +} diff --git a/.nvmrc b/.nvmrc index 016e34ba..54c65116 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20.17.0 +v24 diff --git a/app/(app)/(tsandcs)/privacy/page.mdx b/app/(app)/(tsandcs)/privacy/page.mdx index 2fa94cc3..17630f97 100644 --- a/app/(app)/(tsandcs)/privacy/page.mdx +++ b/app/(app)/(tsandcs)/privacy/page.mdx @@ -70,7 +70,7 @@ Want to learn more about what we do with any information we collect? Review the **Personal information you disclose to us** -**_In Short:_** *We collect personal information that you provide to us.* +***In Short:*** *We collect personal information that you provide to us.* We collect personal information that you voluntarily provide to us when you register on the Services, express an interest in obtaining information about us or our products and Services, when you participate in activities on the Services, or otherwise when you contact us. @@ -96,7 +96,7 @@ All personal information that you provide to us must be true, complete, and accu **2\. HOW DO WE PROCESS YOUR INFORMATION?** -**_In Short:_** *We process your information to provide, improve, and administer our Services, communicate with you, for security and fraud prevention, and to comply with law. We may also process your information for other purposes with your consent.* +***In Short:*** *We process your information to provide, improve, and administer our Services, communicate with you, for security and fraud prevention, and to comply with law. We may also process your information for other purposes with your consent.* **We process your personal information for a variety of reasons, depending on how you interact with our Services, including:** @@ -158,7 +158,7 @@ In some exceptional cases, we may be legally permitted under applicable law to p **4\. WHEN AND WITH WHOM DO WE SHARE YOUR PERSONAL INFORMATION?** -**_In Short:_** *We may share information in specific situations described in this section and/or with the following third parties.* +***In Short:*** *We may share information in specific situations described in this section and/or with the following third parties.* We may need to share your personal information in the following situations: @@ -168,7 +168,7 @@ We may need to share your personal information in the following situations: **5\. HOW DO WE HANDLE YOUR SOCIAL LOGINS?** -**_In Short:_** *If you choose to register or log in to our Services using a social media account, we may have access to certain information about you.* +***In Short:*** *If you choose to register or log in to our Services using a social media account, we may have access to certain information about you.* Our Services offer you the ability to register and log in using your third-party social media account details (like your Facebook or Twitter logins). Where you choose to do this, we will receive certain profile information about you from your social media provider. The profile information we receive may vary depending on the social media provider concerned, but will often include your name, email address, friends list, and profile picture, as well as other information you choose to make public on such a social media platform. @@ -176,7 +176,7 @@ We will use the information we receive only for the purposes that are described **6\. HOW LONG DO WE KEEP YOUR INFORMATION?** -**_In Short:_** *We keep your information for as long as necessary to fulfill the purposes outlined in this privacy notice unless otherwise required by law.* +***In Short:*** *We keep your information for as long as necessary to fulfill the purposes outlined in this privacy notice unless otherwise required by law.* We will only keep your personal information for as long as it is necessary for the purposes set out in this privacy notice, unless a longer retention period is required or permitted by law (such as tax, accounting, or other legal requirements). No purpose in this notice will require us keeping your personal information for longer than the period of time in which users have an account with us. @@ -184,19 +184,19 @@ When we have no ongoing legitimate business need to process your personal inform **7\. HOW DO WE KEEP YOUR INFORMATION SAFE?** -**_In Short:_** *We aim to protect your personal information through a system of organizational and technical security measures.* +***In Short:*** *We aim to protect your personal information through a system of organizational and technical security measures.* We have implemented appropriate and reasonable technical and organizational security measures designed to protect the security of any personal information we process. However, despite our safeguards and efforts to secure your information, no electronic transmission over the Internet or information storage technology can be guaranteed to be 100% secure, so we cannot promise or guarantee that hackers, cybercriminals, or other unauthorized third parties will not be able to defeat our security and improperly collect, access, steal, or modify your information. Although we will do our best to protect your personal information, transmission of personal information to and from our Services is at your own risk. You should only access the Services within a secure environment. **8\. DO WE COLLECT INFORMATION FROM MINORS?** -**_In Short:_** *We do not knowingly collect data from or market to children under 18 years of age.* +***In Short:*** *We do not knowingly collect data from or market to children under 18 years of age.* We do not knowingly solicit data from or market to children under 18 years of age. By using the Services, you represent that you are at least 18 or that you are the parent or guardian of such a minor and consent to such minor dependent’s use of the Services. If we learn that personal information from users less than 18 years of age has been collected, we will deactivate the account and take reasonable measures to promptly delete such data from our records. If you become aware of any data we may have collected from children under age 18, please contact us at niall@codu.co. **9\. WHAT ARE YOUR PRIVACY RIGHTS?** -**_In Short:_** *In some regions, such as the European Economic Area (EEA), United Kingdom (UK), Switzerland, and Canada, you have rights that allow you greater access to and control over your personal information. You may review, change, or terminate your account at any time.* +***In Short:*** *In some regions, such as the European Economic Area (EEA), United Kingdom (UK), Switzerland, and Canada, you have rights that allow you greater access to and control over your personal information. You may review, change, or terminate your account at any time.* In some regions (like the EEA, UK, Switzerland, and Canada), you have certain rights under applicable data protection laws. These may include the right (i) to request access and obtain a copy of your personal information, (ii) to request rectification or erasure; (iii) to restrict the processing of your personal information; (iv) if applicable, to data portability; and (v) not to be subject to automated decision-making. In certain circumstances, you may also have the right to object to the processing of your personal information. You can make such a request by contacting us by using the contact details provided in the section "HOW CAN YOU CONTACT US ABOUT THIS NOTICE?" below. @@ -228,7 +228,7 @@ Most web browsers and some mobile operating systems and mobile applications incl **11\. DO UNITED STATES RESIDENTS HAVE SPECIFIC PRIVACY RIGHTS?** -**_In Short:_** *If you are a resident of California, Colorado, Connecticut, Utah or Virginia, you are granted specific rights regarding access to your personal information.* +***In Short:*** *If you are a resident of California, Colorado, Connecticut, Utah or Virginia, you are granted specific rights regarding access to your personal information.* **What categories of personal information do we collect?** diff --git a/app/(app)/[username]/page.tsx b/app/(app)/[username]/page.tsx index 75462db6..c744b1e8 100644 --- a/app/(app)/[username]/page.tsx +++ b/app/(app)/[username]/page.tsx @@ -5,9 +5,10 @@ import { getServerAuthSession } from "@/server/auth"; import { type Metadata } from "next"; import { db } from "@/server/db"; -type Props = { params: { username: string } }; +type Props = { params: Promise<{ username: string }> }; -export async function generateMetadata({ params }: Props): Promise { +export async function generateMetadata(props: Props): Promise { + const params = await props.params; const username = params.username; const profile = await db.query.user.findFirst({ @@ -52,11 +53,10 @@ export async function generateMetadata({ params }: Props): Promise { }; } -export default async function Page({ - params, -}: { - params: { username: string }; +export default async function Page(props: { + params: Promise<{ username: string }>; }) { + const params = await props.params; const username = params?.username; if (!username) { diff --git a/app/(app)/alpha/additional-details/_actions.ts b/app/(app)/alpha/additional-details/_actions.ts index ed4eaad0..dc133a64 100644 --- a/app/(app)/alpha/additional-details/_actions.ts +++ b/app/(app)/alpha/additional-details/_actions.ts @@ -37,7 +37,7 @@ export async function slideOneSubmitAction(dataInput: TypeSlideOneSchema) { return true; } catch (error) { if (error instanceof z.ZodError) { - console.error("Validation error:", error.errors); + console.error("Validation error:", error.issues); } else { console.error("Error updating the User model:", error); } @@ -65,7 +65,7 @@ export async function slideTwoSubmitAction(dataInput: TypeSlideTwoSchema) { return true; } catch (error) { if (error instanceof z.ZodError) { - console.error("Validation error:", error.errors); + console.error("Validation error:", error.issues); } else { console.error("Error updating the User model:", error); } @@ -97,7 +97,7 @@ export async function slideThreeSubmitAction(dataInput: TypeSlideThreeSchema) { return true; } catch (error) { if (error instanceof z.ZodError) { - console.error("Validation error:", error.errors); + console.error("Validation error:", error.issues); } else { console.error("Error updating the User model:", error); } diff --git a/app/(app)/alpha/additional-details/_client.tsx b/app/(app)/alpha/additional-details/_client.tsx index 5e67dac8..4fa0acea 100644 --- a/app/(app)/alpha/additional-details/_client.tsx +++ b/app/(app)/alpha/additional-details/_client.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { redirect, useRouter, useSearchParams } from "next/navigation"; import { useSession } from "next-auth/react"; import { @@ -228,23 +228,21 @@ function SlideTwo({ details }: { details: UserDetails }) { parsedDateOfBirth?.getDate(), ); - const [listOfDaysInSelectedMonth, setListOfDaysInSelectedMonth] = useState([ - 0, - ]); - - useEffect(() => { - // If year or month change, recalculate how many days are in the specified month + // Compute days in month directly from year/month (no state needed) + const listOfDaysInSelectedMonth = useMemo(() => { if (year && month !== undefined) { // Returns the last day of the month, by creating a date with day 0 of the following month. - const nummberOfDaysInMonth = new Date(year, month + 1, 0).getDate(); - const daysArray = Array.from( - { length: nummberOfDaysInMonth }, + const numberOfDaysInMonth = new Date(year, month + 1, 0).getDate(); + return Array.from( + { length: numberOfDaysInMonth }, (_, index) => index + 1, ); - setListOfDaysInSelectedMonth(daysArray); } + return [0]; + }, [year, month]); - // Update the date object when year, month or date change + // Update the date object when year, month or day change + useEffect(() => { if (year && month !== undefined && day) { let selectedDate: Date; @@ -257,7 +255,7 @@ function SlideTwo({ details }: { details: UserDetails }) { } setValue("dateOfBirth", selectedDate.toISOString()); } - }, [year, month, day]); + }, [year, month, day, setValue]); const startYearAgeDropdown = 1950; const endYearAgeDropdown = 2010; diff --git a/app/(app)/alpha/layout.tsx b/app/(app)/alpha/layout.tsx index 642edf85..0c56d862 100644 --- a/app/(app)/alpha/layout.tsx +++ b/app/(app)/alpha/layout.tsx @@ -1,3 +1,4 @@ +import React from "react"; import { notFound } from "next/navigation"; export const metadata = { @@ -8,7 +9,7 @@ export const metadata = { }, }; -export default function Alpha({ children }: { children: ChildNode }) { +export default function Alpha({ children }: { children: React.ReactNode }) { if (process.env.ALPHA || process.env.NODE_ENV === "development") { return <>{children}; } diff --git a/app/(app)/alpha/new/[[...postIdArr]]/_client.tsx b/app/(app)/alpha/new/[[...postIdArr]]/_client.tsx index 69074792..e262d709 100644 --- a/app/(app)/alpha/new/[[...postIdArr]]/_client.tsx +++ b/app/(app)/alpha/new/[[...postIdArr]]/_client.tsx @@ -47,7 +47,8 @@ const Create = () => { useEffect(() => { _setUnsaved(hasUnsavedChanges); - }, [hasUnsavedChanges, _setUnsaved]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hasUnsavedChanges]); return ( <> @@ -197,7 +198,7 @@ const Create = () => { - {dataStatus === "loading" && postId && ( + {dataStatus === "pending" && postId && (
@@ -236,7 +237,7 @@ const Create = () => {
- {saveStatus === "loading" && ( + {saveStatus === "pending" && (

Auto-saving...

)} {saveStatus === "error" && savedTime && ( diff --git a/app/(app)/articles/[slug]/page.tsx b/app/(app)/articles/[slug]/page.tsx index 614e9f6e..605920d0 100644 --- a/app/(app)/articles/[slug]/page.tsx +++ b/app/(app)/articles/[slug]/page.tsx @@ -20,9 +20,10 @@ import DOMPurify from "isomorphic-dompurify"; import type { JSONContent } from "@tiptap/core"; import NotFound from "@/components/NotFound/NotFound"; -type Props = { params: { slug: string } }; +type Props = { params: Promise<{ slug: string }> }; -export async function generateMetadata({ params }: Props): Promise { +export async function generateMetadata(props: Props): Promise { + const params = await props.params; const slug = params.slug; const post = await getPost({ slug }); @@ -32,7 +33,7 @@ export async function generateMetadata({ params }: Props): Promise { const tags = post?.tags.map((tag) => tag.tag.title); if (!post) return {}; - const host = headers().get("host") || ""; + const host = (await headers()).get("host") || ""; return { title: `${post.title} | by ${post.user.name} | Codú`, authors: { @@ -77,11 +78,12 @@ const renderSanitizedTiptapContent = (jsonContent: JSONContent) => { return DOMPurify.sanitize(rawHtml); }; -const ArticlePage = async ({ params }: Props) => { +const ArticlePage = async (props: Props) => { + const params = await props.params; const session = await getServerAuthSession(); const { slug } = params; - const host = headers().get("host") || ""; + const host = (await headers()).get("host") || ""; const post = await getPost({ slug }); diff --git a/app/(app)/articles/_client.tsx b/app/(app)/articles/_client.tsx index 6ad05d83..73e21cd0 100644 --- a/app/(app)/articles/_client.tsx +++ b/app/(app)/articles/_client.tsx @@ -104,7 +104,7 @@ const ArticlesPage = () => { Something went wrong... Please refresh your page.
)} - {status === "loading" && + {status === "pending" && Children.toArray( Array.from({ length: 7 }, () => { return ; @@ -164,7 +164,7 @@ const ArticlesPage = () => { Popular topics
- {tagsStatus === "loading" && } + {tagsStatus === "pending" && } {tagsStatus === "success" && tagsData.data.map(({ title }) => ( () => {}; +const getClientSnapshot = () => true; +const getServerSnapshot = () => false; export const PostAuthPage = (content: { heading: string; subHeading: string; }) => { - const [mounted, setMounted] = useState(false); + const mounted = useSyncExternalStore( + emptySubscribe, + getClientSnapshot, + getServerSnapshot, + ); const { resolvedTheme } = useTheme(); - // useEffect only happens on client not server - useEffect(() => { - setMounted(true); - }, []); - // if on server dont render. needed to prevent a hydration mismatch error if (!mounted) return null; diff --git a/app/(app)/company/[slug]/page.tsx b/app/(app)/company/[slug]/page.tsx index a951a5e5..ec38a34f 100644 --- a/app/(app)/company/[slug]/page.tsx +++ b/app/(app)/company/[slug]/page.tsx @@ -1,4 +1,5 @@ import { notFound } from "next/navigation"; +import Link from "next/link"; import { companies } from "./config"; export const metadata = { @@ -7,9 +8,10 @@ export const metadata = { "Explore our community sponsors. Ninedots Recruitment connects top talent with leading companies in the tech industry.", }; -type Props = { params: { slug: string } }; +type Props = { params: Promise<{ slug: string }> }; -export default async function Page({ params }: Props) { +export default async function Page(props: Props) { + const params = await props.params; const { slug } = params; const company = companies.find((item) => item.slug === slug.toLowerCase()); @@ -63,12 +65,12 @@ export default async function Page({ params }: Props) {
diff --git a/app/(app)/draft/[id]/page.tsx b/app/(app)/draft/[id]/page.tsx index d132565f..ff6c0944 100644 --- a/app/(app)/draft/[id]/page.tsx +++ b/app/(app)/draft/[id]/page.tsx @@ -11,15 +11,16 @@ import { type Metadata } from "next"; import { getPostPreview } from "@/server/lib/posts"; import { getCamelCaseFromLower } from "@/utils/utils"; -type Props = { params: { id: string } }; +type Props = { params: Promise<{ id: string }> }; -export async function generateMetadata({ params }: Props): Promise { +export async function generateMetadata(props: Props): Promise { + const params = await props.params; const { id } = params; const post = await getPostPreview({ id }); if (!post) return {}; - const host = headers().get("host") || ""; + const host = (await headers()).get("host") || ""; return { title: `Draft: ${post.title} | by ${post.user.name} | Codú`, authors: { @@ -34,7 +35,8 @@ export async function generateMetadata({ params }: Props): Promise { }; } -const PreviewPage = async ({ params }: Props) => { +const PreviewPage = async (props: Props) => { + const params = await props.params; const { id } = params; const post = await getPostPreview({ id }); diff --git a/app/(app)/jobs/create/_client.tsx b/app/(app)/jobs/create/_client.tsx index dcbb7acf..7b49f23f 100644 --- a/app/(app)/jobs/create/_client.tsx +++ b/app/(app)/jobs/create/_client.tsx @@ -59,7 +59,7 @@ export default function Content() { const fileInputRef = useRef(null); const [imgUrl, setImgUrl] = useState(null); const [uploadStatus, setUploadStatus] = useState< - "idle" | "loading" | "success" | "error" + "idle" | "pending" | "success" | "error" >("idle"); const onSubmit: SubmitHandler = (values) => { const formData = { @@ -70,12 +70,12 @@ export default function Content() { }; const handleLogoUpload = async (e: React.ChangeEvent) => { - if (uploadStatus === "loading") { + if (uploadStatus === "pending") { return toast.info("Upload in progress, please wait..."); } if (e.target.files && e.target.files.length > 0) { - setUploadStatus("loading"); + setUploadStatus("pending"); const file = e.target.files[0]; const { size, type } = file; @@ -155,9 +155,9 @@ export default function Content() { onClick={() => { fileInputRef.current?.click(); }} - disabled={uploadStatus === "loading"} + disabled={uploadStatus === "pending"} > - {uploadStatus === "loading" ? "Uploading..." : "Change Logo"} + {uploadStatus === "pending" ? "Uploading..." : "Change Logo"} { title="Delete article" subTitle="Are you sure you want to delete this article?" content="All of the data will be permanently removed from our servers forever. This action cannot be undone." - confirmText={deleteStatus === "loading" ? "Deleting..." : "Delete"} + confirmText={deleteStatus === "pending" ? "Deleting..." : "Delete"} cancelText="Cancel" /> )} @@ -113,7 +113,7 @@ const MyPosts = () => {
- {selectedTabData.status === "loading" && ( + {selectedTabData.status === "pending" && (

Fetching your posts...

)} {selectedTabData.status === "error" && ( diff --git a/app/(app)/notifications/_client.tsx b/app/(app)/notifications/_client.tsx index 7cc8ca4e..e2b6e3e8 100644 --- a/app/(app)/notifications/_client.tsx +++ b/app/(app)/notifications/_client.tsx @@ -12,6 +12,23 @@ import { import PageHeading from "@/components/PageHeading/PageHeading"; import { api } from "@/server/trpc/react"; +// Moved outside to avoid "cannot create components during render" error +const Placeholder = () => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+); + const Notifications = () => { const { status, @@ -50,26 +67,10 @@ const Notifications = () => { if (inView && hasNextPage) { fetchNextPage(); } - }, [inView]); + }, [inView, hasNextPage, fetchNextPage]); const noNotifications = !data?.pages[0].data.length; - const Placeholder = () => ( -
-
-
-
-
-
-
-
-
-
-
-
-
- ); - return ( <>
@@ -89,13 +90,13 @@ const Notifications = () => { {status === "error" && (
Something went wrong... Please refresh your page.
)} - {status === "loading" && + {status === "pending" && Children.toArray( Array.from({ length: 7 }, () => { return ; }), )} - {status !== "loading" && noNotifications && ( + {status !== "pending" && noNotifications && (

No new notifications. ✅{" "}

diff --git a/app/(app)/saved/_client.tsx b/app/(app)/saved/_client.tsx index f6e0c4ec..042a2a32 100644 --- a/app/(app)/saved/_client.tsx +++ b/app/(app)/saved/_client.tsx @@ -32,7 +32,7 @@ const SavedPosts = () => {
Saved items
- {bookmarkStatus === "loading" && + {bookmarkStatus === "pending" && Children.toArray( Array.from({ length: 7 }, () => { return ; diff --git a/app/(app)/settings/_client.tsx b/app/(app)/settings/_client.tsx index 04d02cce..b39f45ca 100644 --- a/app/(app)/settings/_client.tsx +++ b/app/(app)/settings/_client.tsx @@ -41,7 +41,7 @@ type User = Pick< >; type ProfilePhoto = { - status: "success" | "error" | "loading" | "idle"; + status: "pending" | "error" | "success" | "idle"; url: string; }; @@ -105,7 +105,7 @@ const Settings = ({ profile }: { profile: User }) => { }; const uploadToUrl = async (signedUrl: string, file: File) => { - setProfilePhoto({ status: "loading", url: "" }); + setProfilePhoto({ status: "pending", url: "" }); if (!file) { setProfilePhoto({ status: "error", url: "" }); @@ -204,7 +204,7 @@ const Settings = ({ profile }: { profile: User }) => { square src={ profilePhoto.status === "error" || - profilePhoto.status === "loading" + profilePhoto.status === "pending" ? undefined : `${profilePhoto.url}` } diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index 008f5145..7ac756a8 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -70,15 +70,18 @@ export default async function Page() { return notFound(); } + // Fetch newsletter status with error handling + let newsletterStatus = existingUser.newsletter; try { - const newsletter = await isUserSubscribedToNewsletter(session.user.email); - const cleanedUser = { - ...existingUser, - newsletter, - }; - return ; + newsletterStatus = await isUserSubscribedToNewsletter(session.user.email); } catch (error) { Sentry.captureException(error); - return ; + // Fall back to existing newsletter status } + + const cleanedUser = { + ...existingUser, + newsletter: newsletterStatus, + }; + return ; } diff --git a/app/(app)/sponsorship/page.tsx b/app/(app)/sponsorship/page.tsx index 91bc9e14..ecdc46b2 100644 --- a/app/(app)/sponsorship/page.tsx +++ b/app/(app)/sponsorship/page.tsx @@ -71,7 +71,11 @@ const Sponsorship = () => {
- + {
- + {
- + {
- + { const [copied, setCopied] = useState(false); const [uploadUrl, setUploadUrl] = useState(null); const [uploadStatus, setUploadStatus] = useState< - "loading" | "error" | "success" | "default" + "pending" | "error" | "success" | "default" >("default"); const [postStatus, setPostStatus] = useState(null); @@ -65,16 +65,16 @@ const Create = ({ session }: { session: Session | null }) => { usePrompt(); useEffect(() => { - _setUnsaved(); + _setUnsaved(unsavedChanges); }, [unsavedChanges, _setUnsaved]); const handleUpload = async (e: React.ChangeEvent) => { - if (uploadStatus === "loading") { + if (uploadStatus === "pending") { return toast.info("Upload in progress, please wait..."); } if (e.target.files && e.target.files.length > 0) { setUploadUrl(null); - setUploadStatus("loading"); + setUploadStatus("pending"); const file = e.target.files[0]; const { size, type } = file; @@ -240,9 +240,9 @@ const Create = ({ session }: { session: Session | null }) => { }; const hasLoadingState = - publishStatus === "loading" || - saveStatus === "loading" || - dataStatus === "loading"; + publishStatus === "pending" || + saveStatus === "pending" || + dataStatus === "pending"; const currentPostStatus = data?.published ? getPostStatus(new Date(data.published)) @@ -624,7 +624,7 @@ const Create = ({ session }: { session: Session | null }) => {
- {dataStatus === "loading" && postId && ( + {dataStatus === "pending" && postId && (
@@ -700,9 +700,9 @@ const Create = ({ session }: { session: Session | null }) => {
diff --git a/app/(standalone)/newsletter/actions.ts b/app/(standalone)/newsletter/actions.ts index 4f6b6198..2c980af3 100644 --- a/app/(standalone)/newsletter/actions.ts +++ b/app/(standalone)/newsletter/actions.ts @@ -4,11 +4,8 @@ import { z } from "zod"; const FormDataSchema = z.object({ email: z - .string({ - required_error: "Email is required", - invalid_type_error: "Must be a valid email address", - }) - .email(), + .string({ error: "Email is required" }) + .email({ error: "Must be a valid email address" }), }); //@TODO - Add sentry to eat errors diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 0ab79c26..86c9f3da 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,7 +1,3 @@ -import NextAuth from "next-auth"; +import { handlers } from "@/auth"; -import { authOptions } from "@/server/auth"; - -const handler = NextAuth(authOptions); - -export { handler as GET, handler as POST }; +export const { GET, POST } = handlers; diff --git a/app/layout.tsx b/app/layout.tsx index 0b53506d..cef3cae0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,7 +4,7 @@ import Fathom from "@/components/Fathom/Fathom"; import A11yProvider from "@/components/A11yProvider/A11yProvider"; import { Toaster } from "sonner"; import { CSPostHogProvider } from "./providers"; -import dynamic from "next/dynamic"; +import PostHogPageView from "@/components/PageViews/PageViews"; import ThemeProvider from "@/components/Theme/ThemeProvider"; import { TRPCReactProvider } from "@/server/trpc/react"; @@ -12,13 +12,6 @@ import AuthProvider from "@/context/AuthProvider"; import ProgressBar from "@/components/ProgressBar/ProgressBar"; import { PromptProvider } from "@/components/PromptService"; -const PostHogPageView = dynamic( - () => import("@/components/PageViews/PageViews"), - { - ssr: false, - }, -); - // @TODO layout app in way that doesn't need to use client session check export const metadata = { title: "Codú - The Web Developer Community", @@ -69,6 +62,13 @@ export default async function RootLayout({ }: { children: React.ReactNode; }) { + // Serialize headers for client component + const headersList = await headers(); + const headersObject: Record = {}; + headersList.forEach((value, key) => { + headersObject[key] = value; + }); + return ( - + {children} diff --git a/app/og/route.tsx b/app/og/route.tsx index df17744d..2a4a0dc8 100644 --- a/app/og/route.tsx +++ b/app/og/route.tsx @@ -30,130 +30,124 @@ export async function GET(request: Request) { ).then((res) => res.arrayBuffer()); return new ImageResponse( - ( -
+ + +
+
+
+
- - -
-
-
-
- planet - {/* Main content */} -
-
- Codu Logo + /> + planet + {/* Main content */} +
+
+ Codu Logo +
+
+
+ {title}
-
-
- {title} -
-
-
-
- {author} -
-
- {`${formatDate(date)} · ${readTime} min read`} -
+
+
+
+ {author} +
+
+ {`${formatDate(date)} · ${readTime} min read`}
- ), +
, { fonts: [ { diff --git a/app/verify-email/_client.tsx b/app/verify-email/_client.tsx index bf75df10..088f28b9 100644 --- a/app/verify-email/_client.tsx +++ b/app/verify-email/_client.tsx @@ -3,25 +3,23 @@ import { Button } from "@headlessui/react"; import { AlertCircle, CheckCircle, Loader } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; function Content() { const params = useSearchParams(); const router = useRouter(); const [status, setStatus] = useState< - "idle" | "loading" | "success" | "error" + "idle" | "pending" | "success" | "error" >("idle"); const [message, setMessage] = useState(""); - const [token, setToken] = useState(null); + // Get token directly from params (no need for separate state) + const token = params.get("token"); + const hasVerified = useRef(false); useEffect(() => { - const tokenParam = params.get("token"); - if (tokenParam && !token) { - setToken(tokenParam); - } - }, [params, token]); + // Prevent double verification in strict mode + if (hasVerified.current) return; - useEffect(() => { const verifyEmail = async () => { if (!token) { setStatus("error"); @@ -30,7 +28,8 @@ function Content() { ); return; } - setStatus("loading"); + hasVerified.current = true; + setStatus("pending"); try { const res = await fetch(`/api/verify-email?token=${token}`); @@ -41,7 +40,7 @@ function Content() { setStatus("error"); } setMessage(data.message); - } catch (error) { + } catch { setStatus("error"); setMessage( "An error occurred during verification. Please try again later.", @@ -60,7 +59,7 @@ function Content() {
Verifying your email address
- {status === "loading" && ( + {status === "pending" && (

diff --git a/auth.ts b/auth.ts new file mode 100644 index 00000000..526f3886 --- /dev/null +++ b/auth.ts @@ -0,0 +1,117 @@ +import NextAuth from "next-auth"; +import GitHub from "next-auth/providers/github"; +import GitLab from "next-auth/providers/gitlab"; +import Nodemailer from "next-auth/providers/nodemailer"; +import { DrizzleAdapter } from "@auth/drizzle-adapter"; +import { db } from "@/server/db"; +import { user } from "@/server/db/schema"; +import { createWelcomeEmailTemplate } from "@/utils/createEmailTemplate"; +import { createPasswordLessEmailTemplate } from "@/utils/createPasswordLessEmailTemplate"; +import { manageNewsletterSubscription } from "@/server/lib/newsletter"; +import sendEmail, { nodemailerSesTransporter } from "@/utils/sendEmail"; +import * as Sentry from "@sentry/nextjs"; + +export const { handlers, auth, signIn, signOut } = NextAuth({ + // @ts-expect-error - DrizzleAdapter type mismatch with next-auth internal types + adapter: DrizzleAdapter(db, { + // @ts-expect-error - Custom user table + usersTable: user, + }), + providers: [ + GitHub({ + clientId: process.env.GITHUB_ID!, + clientSecret: process.env.GITHUB_SECRET!, + }), + GitLab({ + clientId: process.env.GITLAB_ID!, + clientSecret: process.env.GITLAB_SECRET!, + }), + Nodemailer({ + server: { + // Using custom sendVerificationRequest, so this is not used + host: "", + port: 0, + auth: { user: "", pass: "" }, + }, + from: process.env.ADMIN_EMAIL, + async sendVerificationRequest({ identifier, url }) { + try { + if (!process.env.ADMIN_EMAIL) { + throw new Error("ADMIN_EMAIL not set"); + } + await nodemailerSesTransporter.sendMail({ + to: identifier, + from: process.env.ADMIN_EMAIL, + subject: `Sign in to Codú 🚀`, + text: `Sign in to Codú 🚀\n\n`, + html: createPasswordLessEmailTemplate(url), + }); + } catch (error) { + Sentry.captureException(error); + throw new Error(`Sign in email could not be sent`); + } + }, + }), + ], + pages: { + signIn: "/get-started", + newUser: "/settings", + verifyRequest: "/auth", + error: "/auth/error", + }, + callbacks: { + session({ session, user }) { + if (session.user) { + session.user.id = user.id; + session.user.role = user.role; + } + return session; + }, + async signIn({ user }) { + try { + const userIsBanned = await db.query.banned_users.findFirst({ + where: (banned_users, { eq }) => eq(banned_users.userId, user.id), + }); + return !userIsBanned; + } catch (error) { + console.error("Error checking banned users:", error); + Sentry.captureException(error); + // Fail closed: reject sign-in on error to maintain security + return false; + } + }, + }, + events: { + async createUser({ user }) { + const { email } = user; + + if (!email) { + console.error("Missing email so cannot send welcome email"); + Sentry.captureMessage("Missing 'email' so cannot send welcome email"); + return; + } + const htmlMessage = createWelcomeEmailTemplate(user?.name || undefined); + + // Subscribe to newsletter (separate try/catch so it doesn't block welcome email) + try { + await manageNewsletterSubscription(email, "subscribe"); + } catch (error) { + console.error("Failed to subscribe user to newsletter:", error); + Sentry.captureException(error); + } + + // Send welcome email + try { + await sendEmail({ + recipient: email, + htmlMessage, + subject: + "Thanks for Joining Codú 🎉 + Your Exclusive Community Invite.", + }); + } catch (error) { + console.error("Failed to send welcome email:", error); + Sentry.captureException(error); + } + }, + }, +}); diff --git a/components/ArticleMenu/ArticleMenu.tsx b/components/ArticleMenu/ArticleMenu.tsx index 1c97ec37..f8b1f4c8 100644 --- a/components/ArticleMenu/ArticleMenu.tsx +++ b/components/ArticleMenu/ArticleMenu.tsx @@ -6,7 +6,7 @@ import { PopoverPanel, Transition, } from "@headlessui/react"; -import React, { Fragment, useEffect, useState } from "react"; +import React, { Fragment, useEffect, useMemo, useState } from "react"; import { api } from "@/server/trpc/react"; @@ -20,11 +20,6 @@ import { type Session } from "next-auth"; import { signIn } from "next-auth/react"; import { ReportModal } from "../ReportModal/ReportModal"; -interface CopyToClipboardOption { - label: string; - href: string; -} - interface Props { session: Session | null; postId: string; @@ -41,25 +36,23 @@ const ArticleMenu = ({ postUrl, }: Props) => { const [copied, setCopied] = useState(false); - const [copyToClipboard, setCopyToClipboard] = useState( - { - label: "", - href: "", - }, + + // Compute label from copied state (no side effect needed) + const label = useMemo( + () => (copied ? "Copied!" : "Copy to clipboard"), + [copied], ); - const { label, href } = copyToClipboard; + // Get href on client side only + const href = typeof window !== "undefined" ? window.location.href : ""; const { data, refetch } = api.post.sidebarData.useQuery({ id: postId, }); useEffect(() => { - setCopyToClipboard({ - label: copied ? "Copied!" : "Copy to clipboard", - href: location.href, - }); - const to = setTimeout(setCopied, 1000, false); + if (!copied) return; + const to = setTimeout(() => setCopied(false), 1000); return () => clearTimeout(to); }, [copied]); @@ -77,7 +70,7 @@ const ArticleMenu = ({ }); const likePost = async (postId: string, setLiked = true) => { - if (likeStatus === "loading") return; + if (likeStatus === "pending") return; try { await like({ postId, setLiked }); } catch (err) { @@ -87,7 +80,7 @@ const ArticleMenu = ({ }; const bookmarkPost = async (postId: string, setBookmarked = true) => { - if (bookmarkStatus === "loading") return; + if (bookmarkStatus === "pending") return; try { await bookmark({ postId, setBookmarked }); } catch (err) { diff --git a/components/ArticlePreview/ArticlePreview.tsx b/components/ArticlePreview/ArticlePreview.tsx index 3b545377..2ac0108e 100644 --- a/components/ArticlePreview/ArticlePreview.tsx +++ b/components/ArticlePreview/ArticlePreview.tsx @@ -39,7 +39,7 @@ type Props = { menuOptions?: Array; showBookmark?: boolean; bookmarkedInitialState?: boolean; - likes: number; + likes?: number; }; const ArticlePreview: NextPage = ({ @@ -86,7 +86,7 @@ const ArticlePreview: NextPage = ({ }); const bookmarkPost = async (postId: string, setBookmarked = true) => { - if (bookmarkStatus === "loading") return; + if (bookmarkStatus === "pending") return; try { if (!session) { signIn(); diff --git a/components/Comments/CommentsArea.tsx b/components/Comments/CommentsArea.tsx index 6e9e621e..24db1d4c 100644 --- a/components/Comments/CommentsArea.tsx +++ b/components/Comments/CommentsArea.tsx @@ -86,7 +86,7 @@ const CommentsArea = ({ postId, postOwnerId }: Props) => { const likeComment = async (commentId: number) => { if (!session) return signIn(); - if (likeStatus === "loading") return; + if (likeStatus === "pending") return; try { await like({ commentId }); } catch (err) { @@ -357,7 +357,7 @@ const CommentsArea = ({ postId, postOwnerId }: Props) => { resetField("reply"); setShowCommentBoxId(null); }} - loading={createCommentStatus === "loading"} + loading={createCommentStatus === "pending"} />

)} @@ -370,7 +370,7 @@ const CommentsArea = ({ postId, postOwnerId }: Props) => { name="edit" id={id} editMode - loading={editStatus === "loading"} + loading={editStatus === "pending"} onCancel={() => setEditCommentBoxId(null)} /> )} @@ -449,14 +449,14 @@ const CommentsArea = ({ postId, postOwnerId }: Props) => { )}